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


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

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

## 選択肢の表示

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

### serializerの拡張

>ソース: `26e0b3a` → `460d4b0`

- Question取得時にChoiceも取得したいので，QuestionSerializerを拡張します． 

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

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

> restframeworkの[公式ドキュメント](http://www.django-rest-framework.org/api-guide/relations/)

modelの定義で， **Choice** から **Question** に `ForeignKey` でリレーションを貼っているので， **Question** インスタンスから **Choice** インスタンスへの逆参照は `choice_set` で取れます．

> 正確には `choice_set` は **RelatedManager** です

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

```python:tutorial/polls/serializers.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](https://qiita-image-store.s3.amazonaws.com/0/58048/436cfd19-f95b-2b79-85bf-a57844e5eaf7.png "Kobito.tyEMBO.png")

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

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

![Kobito.cfFzbi.png](https://qiita-image-store.s3.amazonaws.com/0/58048/619dd295-8800-ea41-d9fa-0065c29dca1f.png "Kobito.cfFzbi.png")

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

## 画面側に選択肢の表示

>ソース: `460d4b0` → `52c4c63`

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

> vuetify [セレクト用コンポーネント](https://vuetifyjs.com/ja/components/selection-controls)

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

```vuejs: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にチェックがつきません．

```vue:frontend/src/components/Poll/Index.vue
data () {
  return {
    vote: null,  // <--- 追加
    questions: [],
  }
},
```

- 修正結果はこのようになります．

![Kobito.rHAyN0.png](https://qiita-image-store.s3.amazonaws.com/0/58048/f40d7a6f-1bbe-57bc-55e0-8e1f975c7f4b.png "Kobito.rHAyN0.png")


## 投票用APIの追加

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

### api_views.pyの拡張

>ソース: `52c4c63` → `fd29d0e`

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

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

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

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

```python: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)
```

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

```python:本家チュートリアルの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の設定

>ソース: `fd29d0e ` → `74f4aab`

続いて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) でも動きます．

```python: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/](http://localhost:8000/api/1.0/choices/1/vote/)

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

![Kobito.3Yi9y5.png](https://qiita-image-store.s3.amazonaws.com/0/58048/810bbc39-0c3b-f471-884b-84bf6006bbe3.png "Kobito.3Yi9y5.png")

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

![Kobito.C2RFgC.png](https://qiita-image-store.s3.amazonaws.com/0/58048/4491573b-98dd-6695-e317-4c4b8636ce5f.png "Kobito.C2RFgC.png")

## 投票処理の追加

>ソース: `74f4aab ` → `8964086`

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

```vue: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()
      })
    },
  },
```

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

## ボタンの無効化

>ソース: `74f4aab` → `5cc7817`

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

![Kobito.uJHf2b.png](https://qiita-image-store.s3.amazonaws.com/0/58048/6d4fb592-9857-f464-db14-9bf1982e2baa.png "Kobito.uJHf2b.png")

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

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

```vue: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](https://qiita-image-store.s3.amazonaws.com/0/58048/7fa6b1a6-20c9-3609-14e9-9d4247903ea1.png "Kobito.ab37uk.png")

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

## APIのモジュール化

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

>ソース: `5cc7817` → `537c241`

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://..."'` のようにシングルクォーテーションの後にダブルクォーテーションで囲んでます．

```javascript: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` を変更し，変数に置き換えます．

```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` コマンドを実行しましょう．

## プラグイン化

>ソース: `537c241` → `6de7f42`

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

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

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

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

```javascript: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の中身はこんな感じになります．

```javascript: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で読み込ませます．

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

Vue.config.productionTip = false

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

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

```vuejs: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へ](https://qiita.com/maisuto/items/41d30abe51f802e8dbf0)

→ [チュートリアルまとめ](http://qiita.com/maisuto/items/17653f344d1f64afa019)