Python
Django
vue.js

Python Django チュートリアル SPA編(4)

勉強会用の資料です.
今回の記事では選択に対する投票処理を追加していきます.

ソースコードはgithubで管理してます.
https://github.com/usa-mimi/tutorial-spa
今回の記事から開始する人は tutorial4-start のタグから開始してください.

選択肢の表示

前回までの記事で質問一覧取得APIを作成しました.
投票アプリでは質問に対する選択肢を表示し,そのいずれかに投票を行います.
そのため,質問と,それに対する選択肢を表示する必要があります.
まず前回作成したQuestionSerializserを拡張し,選択肢も取得できるようにします.

serializerの拡張

ソース: 26e0b3a460d4b0

  • Question取得時にChoiceも取得したいので,QuestionSerializerを拡張します.

質問内容はChoiceSerializserを作っているので,QuestionSerializerの戻り値にChoiceSerializerを追加します.

前回まではとりあえずで作っていたのでChoiceSerializerの定義がQuestionSerializerの下に来てますが,QuestionSerializerの中で使用するのでChoiceSerializerの定義を上に移動させてます.

restframeworkの公式ドキュメント

modelの定義で, Choice から QuestionForeignKey でリレーションを貼っているので, Question インスタンスから Choice インスタンスへの逆参照は choice_set で取れます.

正確には choice_setRelatedManager です

APIの戻り値としては choices という名前で返したいので,serializerのフィールド名は choicesにし,sourcechoice_set を指定します.
逆参照の場合,1対多の関係になるので, many=True のオプションも必要です.
最後に,Metafieldschoices を追加します.

tutorial/polls/serializsers.py
...
class ChoiceSerializer(serializers.ModelSerializer):  # QuestionSerializerの上に移動
...

class QuestionSerializer(serializers.ModelSerializer):
    choices = ChoiceSerializer(many=True, source='choice_set')  # <----追加

    class Meta:
        model = Question
        fields = (
            'id',
            'question_text',
            'pub_date',
            'choices',  # <----追加
        )

開発サーバーを起動して確認

runserver で開発サーバを起動し, 管理サイト( http://localhost:8000/admin/ )からテスト用に choice をいくつか追加しましょう.

Kobito.tyEMBO.png

いくつか追加したら,APIを叩いてみて戻り値を確認しましょう.

http://localhost:8000/api/1.0/questions/ をブラウザで開くとAPIの戻り値が確認できます.

Kobito.cfFzbi.png

APIの戻り値に choices というフィールドが追加され,中に追加した選択肢の内容が出ているのが確認できると思います.

画面側に選択肢の表示

ソース: 460d4b052c4c63

vue側を修正し,投票用に選択肢とradio,投票数,投票ボタンを付けてみます.
question の戻り値に choices が追加されたので,それを v-for で回してradio用のコンポーネントを表示します.

vuetify セレクト用コンポーネント

カードに <v-radio-group> を追加します.
選択肢がない場合は表示する必要がないので, <v-card-text>v-if="data.choices.length" を記述し,
choice がない場合は表示しないようにします.

frontend/src/components/Poll/Index.vue
                <v-card-title primary-title>
                  <div>{{ data.questionText }}</div>
                </v-card-title>
                <v-card-text v-if="data.choices.length">
                   <v-radio-group v-model="vote">
                     <v-radio
                       v-for="choice in data.choices"
                       :key="choice.id"
                       :label="choice.choiceText + ' 投票数: ' + choice.votes"
                       :value="choice.id">
                     </v-radio>
                   </v-radio-group>
                   <v-btn color="success">投票</v-btn>
                </v-card-text>
                <v-card-text>
                  <div>{{ data.pubDate|printDate }}</div>
                </v-card-text>

選択した choice の保存先はとりあえず vote にしました.
script内の data にこの変数を追加しておきます.

追加しないと選択した値を保存できないのでradioにチェックがつきません.

frontend/src/components/Poll/Index.vue
data () {
  return {
    vote: null,  // <--- 追加
    questions: [],
  }
},
  • 修正結果はこのようになります.

Kobito.rHAyN0.png

投票用APIの追加

投票の準備ができたので,今度は投稿のためのAPIを追加します.
APIのURLの決め方は色々ありますが,今回は choices/<id>/vote/ に対してPOSTすると投票数が増えるように作ってみます.

api_views.pyの拡張

ソース: 52c4c63fd29d0e

まずは tutorial/polls/api_views.py を拡張し,投票用のViewクラスを作ります.

rest_frameworkgenerics.py の中にRESTFramework用の get_object_or_404 を持っているのでこいつを利用します.
使い方は本家(django.shortcuts)の get_object_or_404 と同じです.
(オブジェクトがなかったときに排出されるNotFound例外がRESTFramewor用のクラスになってます.)

正常に選択肢が取得できたい場合は投票数(voteフィールド)をインクリメントします.
更新APIを叩いた後は更新結果を表示することが多いので,今回は ChoiceSerializerChoice の中身を返してあげます.

serializer_class の指定はAPI的には必要ありませんが,ブラウザでの表示用につけています.

polls/api_views.py
from rest_framework.viewsets import ReadOnlyModelViewSet
from rest_framework import generics  # 追加
from rest_framework.response import Response  # 追加

from .models import Question, Choice  # Choice追加
from .serializers import QuestionSerializer, ChoiceSerializer  # ChoiceSerializer追加

...

class VoteView(generics.CreateAPIView):
    serializer_class = ChoiceSerializer  # ブラウザでの画面表示用
    def post(self, request, choice_id, *args, **kwargs):
        obj = generics.get_object_or_404(
            queryset=Choice.objects.all(),
            id=choice_id,
        )
        obj.votes += 1
        obj.save()
        s = ChoiceSerializer(instance=obj)
        return Response(s.data)

ちなみに本家チュートリアルのvoteメソッドはこんな感じ.
やってることはほぼ同じですが, Question に紐づく choice を取得できるかどうかのチェックが入っており,
取得出来ない場合は選択画面を再度表示させています.
また,成功した場合は完了画面にジャンプさせています.

本家チュートリアルのviews.py
from django.shortcuts import get_object_or_404, render
from django.http import HttpResponseRedirect, HttpResponse
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:
        selected_choice = question.choice_set.get(pk=request.POST['choice'])
    except (KeyError, Choice.DoesNotExist):
        # Redisplay the question voting form.
        return render(request, 'polls/detail.html', {
            'question': question,
            'error_message': "You didn't select a choice.",
        })
    else:
        selected_choice.votes += 1
        selected_choice.save()
        # Always return an HttpResponseRedirect after successfully dealing
        # with POST data. This prevents data from being posted twice if a

        # user hits the Back button.
        return HttpResponseRedirect(reverse('polls:results', args=(question.id,)))

URLの設定

ソース: fd29d0e74f4aab

続いてURLの設定です.
polls.api_urls.py で定義した question_router を拡張する方法もありますが,
今回はURLの切り方をQuestionから独立させたので tutorial/urls.py のほうに直に記述します.

django2.0からは path メソッドで引数の型指定,変換をしてくれるようになりました.
django1系の場合は url('^choices/<?P(choice_id)\d+)>/vote/$', poll_views.VoteView.as_view()), と書けます.

型変換はしてくれないので,年月日をURLで受け取り datetime.date(year, month, day) のように書く際には,int(year) などと書き,型変換する必要があります.

Modelクラスのfilter等で使用する場合はdjango内部で型変更がかかるので get(id='1') でも get(id=1) でも動きます.

tutorial/urls.py
from polls.api_urls import question_router
from polls import api_views as poll_views  # 追加


api_urlpatterns = [
    path('questions/', include(question_router.urls)),
    path('choices/<int:choice_id>/vote/', poll_views.VoteView.as_view()),  # 追加
]

ブラウザーで確認

$python manage.py runserver で開発サーバを起動し,ブラウザで確認しましょう.
新しく追加したAPIのURLは以下の通りです.

http://localhost:8000/api/1.0/choices/1/vote/

ブラウザを開くと以下のような画面が表示されるはずです.
今回はGETメソッドは作っていないので,初回表示時には405のエラーになります.
Question, Choice text, Votes の入力用Formが出ているのは serializer_class を設定したからです.

Kobito.3Yi9y5.png

POSTを押すとchoiceの内容が返ってきます.
POSTを押す毎に votes がインクリメントされているはずです.

Kobito.C2RFgC.png

投票処理の追加

ソース: 74f4aab8964086

投稿用APIが出来たので,front側から実際にAPIを叩いて投票処理を行ってみます.
methodsの中に投票用のメソッドを追加し,予め作っておいた投票ボタンをクリックした際にこのメソッドを呼び出すようにします.
投票が完了したらデータの更新が必要になるので,投票成功時には fetchData を呼び出してあげます.

frontend/src/components/Poll/Index.vue
# template
                 </v-radio-group>
                 <v-btn @click="doVote" color="success">投票</v-btn>  <!-- @click="doVote" 追加 -->
              </v-card-text>
...
# script
  methods: {
    fetchData () {
      axios.get('http://localhost:8000/api/1.0/questions/').then(res => {
        this.questions = res.data.results
      })
    },
    doVote () {  // 投票用メソッド追加
      if (!this.vote) {
        return
      }
      axios.post(`http://localhost:8000/api/1.0/choices/${this.vote}/vote/`).then(res => {
        this.fetchData()
      })
    },
  },

ひとまずここまでで選択肢をラジオで選択肢,投票ボタンを押すと投票数が増える,という動作が完成しました.

ボタンの無効化

ソース: 74f4aab5cc7817

今選択されている投票先(Question)は画面内で共通の変数 (vote) に入れているので,
どの投票ボタンを押しても投票が行えてしまいます.

Kobito.uJHf2b.png

この動作は少しかっこ悪いので,別のカードの質問が選択されている場合はボタンを無効にしてみます.

ボタンは :diabled="true" というディレクティブを付けることで無効にできます.
今回は選択肢が有効であるかどうかを判定する関数を作成し,有効でない場合に無効にする処理を追加します.

frontend/src/components/Poll/Index.vue
# template
<v-btn @click="doVote" color="success" :disabled="!voteEnable(data.choices)">投票</v-btn>
// カード毎の選択肢を渡し,"有効でない場合" にtrueなので ! でメソッドの戻り値を反転
...
# script
  methods: {
    ...
    voteEnable (choices) {
      if (!this.vote) {
        return false  // radioの選択がない場合は当然false
      }
      return choices.some(x => x.id === this.vote) // 選択されたidが選択肢の中に含まれていればtrue
    },
  },
...

Kobito.ab37uk.png

これで選択されていないカードの投票ボタンが無効化されます.

APIのモジュール化

共通部分を環境編に外出し

ソース: 5cc7817537c241

APIのURLは http://localhost:8000/api/1.0/... になっていますが,
サイトを公開する際には当然localhostではなく別のアドレスになります.

そこで,APIの共通部分 (http://localhost:8000/api/1.0/) を環境変数で定義し,
本番と開発で切り替えられるようにしていきます.

実行時の環境変数は frontend/config/ 以下にある env.js ファイルを編集することで設定できます.
デフォルトでは dev.env.js, prod.env.js, test.env.js の3つがあると思いますが,
それぞれ名前の通り開発用,本番用,テスト用の設定ファイルです.
今回は dev.env.js に開発用のAPIのURLを設定します.

注意する点として,設定した文字列はjavascriptとして評価されます.
例えば HOGE: 'hoge', のように書くと, hogeという変数の値がセットされます.
ここでは文字列そのものをセットしたいので, '"http://..."' のようにシングルクォーテーションの後にダブルクォーテーションで囲んでます.

frontend/config/dev.env.js
'use strict'
const merge = require('webpack-merge')
const prodEnv = require('./prod.env')

module.exports = merge(prodEnv, {
  NODE_ENV: '"development"',  // <---- 追加
  API_ENDPOINT: '"http://localhost:8000/api/1.0/"',
})
  • 続いてAPIの使用箇所を変更します.

上記の設定ファイルでセットした環境変数は process.env.HOGE で呼び出せます.
frontend/src/components/Poll/Index.vue を変更し,変数に置き換えます.

frontend/src/components/Poll/Index.vue
      axios.get('http://localhost:8000/api/1.0/questions/').then(res => {
      ↓
      axios.get(`${process.env.API_ENDPOINT}questions/`).then(res => {
...
      axios.post(`http://localhost:8000/api/1.0/choices/${this.vote}/vote/`).then(res => {
      ↓
      axios.post(`${process.env.API_ENDPOINT}choices/${this.vote}/vote/`).then(res => {

これでAPIサーバやバージョンが変わっても変更が容易になりました.
ページをリロードして同じように動いていることを確認してみましょう.

環境変数の追加時には開発サーバの再起動が必要になります.
すでに開発サーバを起動している場合は一度実行を停止し,再度 $ npm run dev コマンドを実行しましょう.

プラグイン化

ソース: 537c2416de7f42

ページの表示と投稿処理で,現在は2つのAPIを使用しています.
今のところは画面が1つしかなく,APIの種類も2つですが,このまま開発を進めていき,
APIのURLに変更があった場合,修正が大変になります.
またコンポーネント内でaxiosを使って直に記述した場合,URLとメソッド名(getpost など)を見て
どういうAPIを使用しているか類推する必要がでて,コードわかりづらくなります.
そこでAPIへ接続するための処理をモジュール化し,コンポーネント内で使いやすくしていきます.

  • api用のプラグインモジュールを /src/api/ 以下に作成します.

axioscreate メソッドを使用することでbaseURLを設定したインスタンスを作成することができます.
これでapi呼び出し時のベースとなる http://localhost:8000/api/1.0/ の部分(環境変数にした部分)を省略できます.

Vueでプラグインをつくる場合, install メソッドを用意する必要があります.
今回はコンポーネント内から this.$request で作成したいaxiosインスタンスを取得できるようにします.
さらに,APIは数が増えていくことが予想されるので,機能単位(今回はquestions)でサブモジュール化します.
index.jsの中身はこんな感じになります.

frontend/src/api/index.js
import axios from 'axios'

import questions from './questions'

const client = axios.create({
  baseURL: process.env.API_ENDPOINT,
})

client.questions = questions(client)

client.install = function (Vue) {
  Object.defineProperty(Vue.prototype, '$request', {
    get () {
      return client
    },
  })
}

export default client

続いてqustionsを作成します.
index.js内でaxiosインスタンスを渡して貰うようにし,実際にAPIを叩く処理を書いていきます.
今コンポーネント内で使用しているAPIは質問一覧取得と投票の2つなので,この2つを
list, vote メソッドとしてみます.
vote のほうは選択肢のIDがURLになるのでそれを引数にします.
questions.jsの中身はこんな感じになります.

frontend/src/api/questions.js
export default function (cli) {
  return {
    list () {
      return cli.get('questions/')
    },
    vote (choiceId) {
      return cli.post(`choices/${choiceId}/vote/`)
    },
  }
}

最後にmain.jsで読み込ませます.

frontend/src/main.js
...
import api from './api'  // <---追加

Vue.config.productionTip = false

Vue.use(Vuetify)
Vue.use(api) // <---追加
...

これでコンポーネント内で使用する準備が整いました.
axiosを使用していた箇所を置き換えましょう.

frontend/src/compoentns/Poll/Index.vue
  methods: {
    fetchData () {
      this.$request.questions.list().then(res => {  // <-- 置き換え
        this.questions = res.data.results
      })
    },
    doVote () {
      if (!this.vote) {
        return
      }
      this.$request.questions.vote(this.vote).then(res => { // <-- 置き換え
        this.fetchData()
      })
    },
    ...

次はユーザ認証,ログイン周りを作っていきます.

チュートリアル5へ

チュートリアルまとめ