勉強会用の資料です.
今回の記事では選択に対する投票処理を追加していきます.
ソースコードは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の公式ドキュメント
modelの定義で, Choice から Question に ForeignKey
でリレーションを貼っているので, Question インスタンスから Choice インスタンスへの逆参照は choice_set
で取れます.
正確には
choice_set
は RelatedManager です
APIの戻り値としては choices
という名前で返したいので,serializerのフィールド名は choices
にし,source
で choice_set
を指定します.
逆参照の場合,1対多の関係になるので, many=True
のオプションも必要です.
最後に,Meta
の fields
に choices を追加します.
...
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 をいくつか追加しましょう.
いくつか追加したら,APIを叩いてみて戻り値を確認しましょう.
http://localhost:8000/api/1.0/questions/ をブラウザで開くとAPIの戻り値が確認できます.
APIの戻り値に choices
というフィールドが追加され,中に追加した選択肢の内容が出ているのが確認できると思います.
画面側に選択肢の表示
ソース:
460d4b0
→52c4c63
vue側を修正し,投票用に選択肢とradio,投票数,投票ボタンを付けてみます.
question
の戻り値に choices
が追加されたので,それを v-for
で回してradio用のコンポーネントを表示します.
vuetify セレクト用コンポーネント
カードに <v-radio-group>
を追加します.
選択肢がない場合は表示する必要がないので, <v-card-text>
に v-if="data.choices.length"
を記述し,
choice がない場合は表示しないようにします.
<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にチェックがつきません.
data () {
return {
vote: null, // <--- 追加
questions: [],
}
},
- 修正結果はこのようになります.
投票用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的には必要ありませんが,ブラウザでの表示用につけています.
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 を取得できるかどうかのチェックが入っており,
取得出来ない場合は選択画面を再度表示させています.
また,成功した場合は完了画面にジャンプさせています.
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) でも動きます.
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 を設定したからです.
POSTを押すとchoiceの内容が返ってきます.
POSTを押す毎に votes がインクリメントされているはずです.
投票処理の追加
ソース:
74f4aab
→8964086
投稿用APIが出来たので,front側から実際にAPIを叩いて投票処理を行ってみます.
methodsの中に投票用のメソッドを追加し,予め作っておいた投票ボタンをクリックした際にこのメソッドを呼び出すようにします.
投票が完了したらデータの更新が必要になるので,投票成功時には fetchData を呼び出してあげます.
# 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) に入れているので,
どの投票ボタンを押しても投票が行えてしまいます.
この動作は少しかっこ悪いので,別のカードの質問が選択されている場合はボタンを無効にしてみます.
ボタンは :disabled="true"
というディレクティブを付けることで無効にできます.
今回は選択肢が有効であるかどうかを判定する関数を作成し,有効でない場合に無効にする処理を追加します.
# 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
},
},
...
これで選択されていないカードの投票ボタンが無効化されます.
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://..."'
のようにシングルクォーテーションの後にダブルクォーテーションで囲んでます.
'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
を変更し,変数に置き換えます.
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でプラグインをつくる場合, install メソッドを用意する必要があります.
今回はコンポーネント内から this.$request
で作成したいaxiosインスタンスを取得できるようにします.
さらに,APIは数が増えていくことが予想されるので,機能単位(今回はquestions)でサブモジュール化します.
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の中身はこんな感じになります.
export default function (cli) {
return {
list () {
return cli.get('questions/')
},
vote (choiceId) {
return cli.post(`choices/${choiceId}/vote/`)
},
}
}
最後にmain.jsで読み込ませます.
...
import api from './api' // <---追加
Vue.config.productionTip = false
Vue.use(Vuetify)
Vue.use(api) // <---追加
...
これでコンポーネント内で使用する準備が整いました.
axiosを使用していた箇所を置き換えましょう.
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()
})
},
...
次はユーザ認証,ログイン周りを作っていきます.