7
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

はじめてのDjango (5) フォームを用いたユーザからのデータの取得とデータベースへの格納

Last updated at Posted at 2019-04-11

はじめに

このドキュメントは,もともとはラボの新メンバー向けの入門テキストとして作成したもので,はじめてDjangoにふれる人にざっとその全体像をつかんでもらうことを狙っています.Djangoの日本語での参考資料も充実してきたので今さらという気がしないでもないですが,見直しを機にQiitaに移すことにしました.万が一でもどなたかの参考になれば幸いです.

説明のための具体例として,Djangoのオフィシャルチュートリアルにある投票アプリをとりあげています(が,説明上の都合で少しコードを追加,変更しているところもあります).素人の独学がベースで,特に前半は公式チュートリアルをやってみた感想のような記事です(今回は全7回中の5回目).

全体のコードはまとめてGitHubに置きました.

変更履歴

  • [2021/04/26] Djangoのバーションを3.2に更新し,それ基づいて内容を微修正しました.

フォームを用いたユーザからのデータの取得とデータベースへの格納

ここまでは,adminサイトから管理者があらじめデータベースに格納しておいたデータをview関数で利用する方法をみてきました.続いて,view関数自体でユーザからデータを取得し,(必要に応じてなんらかの処理を加えた上で)データベースに格納したり,データベースに格納されていたデータを書き換えたりする方法をみていきます.

データベースに格納したいデータのまとまりをデータモデルとしてmodels.pyの中に定義したのを思い出しましょう.それと同じように,ユーザから取得したいデータのまとまりもあらかじめ定義しておくとわかりやすくなります.

このユーザから取得したいデータの構成をフォームと呼び,forms.pyに記述していきます.外側のmysiteディレクトリの中にあるpollsディレクトリの中にforms.pyというファイルを作成し,その中に次の内容を書き込みましょう.

from django import forms
from .models import Question, Choice

class VoteForm(forms.Form):
    CHOICES = [(ch.id, ch.choice_text) for ch in Choice.objects.all()]
    your_choice = forms.ChoiceField(choices = CHOICES)

    def save(self):
        choice_id = self.cleaned_data.get('your_choice')
        selected_choice = Choice.objects.get(pk=choice_id)
        selected_choice.votes += 1
        selected_choice.save()

これは,回答の選択肢(CHOICES)の中からどれかを選ぶかを表す選択データのフォームを定義したものです.

フォームにも,データモデルのときと同じように,取得したいデータ項目のfield(forms.****Field()のように指定される)を列挙していきます.この例では,選択データを表すyour_choiceというfieldのみが定義されています.

フォームで利用可能なfieldはここに整理されています.

save()というメソッドが追加されていますが,これについては後で説明することにして,ひとまず先に進みます.このフォームを利用してユーザからデータを取得できるように,view関数detail()を次のように書き換えます.

from django.shortcuts import redirect
from .forms import *

...

def detail(request, question_id):
    question = get_object_or_404(Question, pk=question_id)

    if request.method == 'POST':
        form = VoteForm(request.POST)
        if form.is_valid():
            form.save()
            return redirect('polls:results', question_id=question.id)
    else:
        form = VoteForm()

    context = {'question':question, 'form':form}
    return render(request, "polls/detail.html", context)

最初に外側のif文以外の部分をみてみると,questionformcontextに加えてrender()を呼び,detailのページを表示していることがわかります.ここに,formとはforms.pyで作成したフォームのインスタンスであり,外側のif文の中で作成されています.

外側のif文では,ユーザからのリクエストがPOSTかどうかを判定しています(フォームにまつわるリクエストの方式としては主にGETとPOSTの2つが使われますが,セキュリティの面でPOSTの方が好ましいため,そちらでないと受け付けないようにしています).

POSTの場合は,受け取ったデータ(request.POST)をVoteForm()に渡してフォームのインスタンス(form)を作成しています.さらに,内側のif文でそのデータの妥当性をチェックし(is_valid()メソッド),この妥当性チェックにもパスした場合は,save()メソッドを呼んでから,resultsのページにリダイレクトしています.

一方,POSTでなかった場合(最初にページを開き,まだデータを送信していないときなど)は,空のフォームのインスタンス(form)を作成しています.

そして,途中でリダイレクトされずに外側のif文から出てくると,detailのページが表示されるようになっています.

これが,フォームを利用するview関数の典型的な流れになります.特に,POSTデータの処理に成功したときは常にredirect()を返すことが推奨されていますので,それに従うようにしましょう.

ここで,save()メソッド中身を,forms.pyの方で確認しておきましょう.

ユーザが入力したデータをそのまま扱うのは危険が伴うので,Djangoが前処理を行ってくれます.この前処理後のデータには,self.cleaned_data.get('field名')でアクセスすることができます.

save()の中では,まずyour_choiceの前処理後のデータをchoice_idという変数に代入しています.そして,そのidのChoiceデータをget()で取り出して,そのvotesの値に1を加えてから,データベースにセーブし直しています.

ここでわかるように,データモデルクラスのインスタンスのsave()メソッドを呼ぶことでそのインスタンスをデータベースにセーブすることができます(このセーブ処理によって初めてデータベース内の値が更新されます).

template(detail.html)の方も,ここまでの内容にあわせて更新しておきましょう.

<h1>{{ question.question_text }}</h1>
<form action="{% url 'polls:detail' question.id %}" method="post">
{% csrf_token %}
{{ form.as_p }}
<input type="submit" value="Vote">
</form>

{% csrf_token %}は,フォームを使ってユーザとデータをやり取りする際にクロスサイトリクエストフォージェリ(csrf)という攻撃を受けるのを防ぐためにDjangoが用意してくれている仕組みです(ので,フォームを使うときにはtemplateに忘れずにこれを記載しておきましょう).

contextを通じて渡されたフォーム(form)は{{ form.as_p }}のところに挿入されます(.as_pをつけると,フォームが<p></p>タグで囲まれ,段落として扱われます).

それでは,開発用サーバを立ち上げて,detailのページがどのように更新されたかをみてみましょう. 回答の選択肢をプルダウンで選んで投票できるようになっています.そして,投票するとresultsのページに飛び,実際に票数に増えていることも確認できます.

これで投票の機能まで実装することができました.ただし,いくつか不満な点もあります.1つ目は,プルダウンでは投票しにくいということです.

これについては,forms.pyの中で,下記のように,your_choiceの引数にwidgetの指定を書き足すと,簡単にラジオボタンに変更することができます(これは好みの問題なので,プルダウンの方が好きという人はもとのままでもいいです).

your_choice = forms.ChoiceField(choices = CHOICES, widget=forms.RadioSelect())

2つ目は,回答の選択肢として,この質問項目に関係のないものまで出てきてしまうことです.続いて,これに対する対策を考えていきましょう.具体的には,フォームを作成するときの初期化処理(__init__()メソッド)をオーバーライドしていきます.

まず,この原因は,VoteFormクラスの1行目にある,CHOICESの定義の仕方にあることを確認しておきます.Choice.objects.all()でクエリセットを作っているので,データベースの中のすべてのChoiceインスタンスが抜き出されてくるわけです.

これにさえ気づけば,質問項目をquestionとおいて,クエリセットをquestion.choice_set.all()に変更すれば解決されることがわかります.ただし,質問項目はその都度異なるので,CHOICESをそのときのquestionに応じて動的に生成できるように工夫する必要があります.

そこで目をつけるのが初期化処理です.質問項目(question)を初期化処理に引数として渡し,それに対応する選択肢のリストをCHOICESとして定義した上で,your_choicefieldをフォームに追加するという手順を考えてみましょう.

これは,フォームのfieldを動的に構成するときの常套手段です.具体的には,VoteFormクラスの定義を次のように書き換えます.

class VoteForm(forms.Form):

    def __init__(self, *args, **kwargs):
        self.question = kwargs.pop('question')
        super(VoteForm, self).__init__(*args, **kwargs)
        CHOICES = [(ch.id, ch.choice_text) for ch in self.question.choice_set.all()]
        self.fields['your_choice'] = forms.ChoiceField(choices = CHOICES, widget=forms.RadioSelect())

    def save(self):
        choice_id = self.cleaned_data.get('your_choice')
        selected_choice = Choice.objects.get(pk=choice_id)
        selected_choice.votes += 1
        selected_choice.save()

VoteFormインスタンスの初期化処理を担当するのが,上の__init__()メソッドです.これを実行する際には,__init__()ではなく(Javaのコンストラクタのように)VoteForm()と書くことになります(が,厳密には,__init__()メソッドはコンストラクタとは言わないらしいです).

引数のうち,selfは自分自身を指すもので,初期化処理を呼び出す際には明示する必要はありません.上のview関数では,VoteForm()VoteForm(request.POST)という2通りの方法で初期化処理を呼び出していたことを思い出しましょう.

引数に質問項目(question)を加えるためにこれらをそれぞれ,VoteForm(question=question)VoteForm(request.POST, question=question)のように書き換えます.

def detail(request, question_id):
    question = get_object_or_404(Question, pk=question_id)

    if request.method == 'POST':
        form = VoteForm(request.POST, question=question)
        if form.is_valid():
            form.save()
            return redirect('polls:results', question_id=question.id)
    else:
        form = VoteForm(question=question)

    context = {'question':question, 'form':form}
    return render(request, "polls/detail.html", context)

これで該当する質問項目に対応する回答の選択肢だけが表示されるようになったはずです.開発用サーバを立ち上げて確認しておきましょう.

続いて,このページにもう少し機能を追加してみます.具体的には,回答したい項目が選択肢に含まれていなかった場合に,このページからそれを追加できるようにします.まずフォームを次のように書き換えます.

class VoteForm(forms.Form):
    new_option = forms.CharField(max_length=200, required=False)

    def __init__(self, *args, **kwargs):
        self.question = kwargs.pop('question')
        super(VoteForm, self).__init__(*args, **kwargs)
        CHOICES = [(ch.id, ch.choice_text) for ch in self.question.choice_set.all()]
        CHOICES.append((-1, 'other option (please specify in the box below)'))
        self.fields['your_choice'] = forms.ChoiceField(choices = CHOICES, widget=forms.RadioSelect())

    def clean_your_choice(self):
        your_choice = self.cleaned_data.get('your_choice')
        return int(your_choice)

    def clean(self):
        cleaned_data = super(VoteForm, self).clean()
        choice_id = self.cleaned_data.get('your_choice')
        new_option = self.cleaned_data.get('new_option')
        if choice_id < 0:
            if not new_option:
                raise forms.ValidationError(
                    'Please specify a new option (or choose an existing one)!'
                    )
        else:
            if new_option:
                raise forms.ValidationError(
                    'Do not specify a new option with also choosing an existing one!'
                    )

    def save(self):
        choice_id = self.cleaned_data.get('your_choice')
        if choice_id < 0:
            new_option = Choice(
                question=self.question,
                choice_text=self.cleaned_data.get('new_option'),
                votes=1
                )
            new_option.save()
        else:
            selected_choice = Choice.objects.get(pk=choice_id)
            selected_choice.votes += 1
            selected_choice.save()

まず,new_optionという文字列のfieldが追加されていることを確認しましょう.required=Falseが指定されているので,空欄のままデータ送信してもエラーにはなりません(これを省くと,空欄チェックが働くようになります).

これまでと同じく,初期化処理の中で選択データのfieldを動的に生成しており,最後にother optionという選択肢を新たに追加しています(idには仮の値として-1を入れています).

save()メソッドをみてみると,idの値が負の場合(すなわち,この新しく追加したother optionが選択された場合),Choiceクラスのインスタンスを生成してそれをデータベースにセーブしていることがみてとれます.

このように,Model名()でデータモデルの新しいインスタンスを生成し,そのfieldに適切な値を代入した後,そのインスタンスのsave()メソッドを呼ぶことで,新しいデータをデータベースの中に格納することができます.

なお,この例のように,オブジェクトの生成とセーブとの間で他の処理を行う必要がない場合は,Model名.objects.create()で同じ流れ(インスタンスの生成とセーブ)をまとめて書くことができます.

次に,新しく追加されている2つのメソッドについてみていきます.これらはユーザが入力したデータの前処理のためのメソッドです.

clean_field名()は対応するfield固有のチェック処理,clean()はフォーム全体に対するチェック処理をそれぞれ担当するもので,(is_valid()メソッドの中で)この順番に自動的に呼ばれます.

clean()メソッドは,Djangoが標準で用意している処理をオーバーライドすることになるので,最初に標準処理(super().clean())を呼ぶことを忘れないようにしましょう.

clean_your_choice()メソッドでは,標準処理ではidの番号が文字列で返されるので,それを整数に変換しています.

clean()メソッドでは,other_optionが選択されたときのみnew_optionへの入力が必要で,そうでないときはnew_optionは空でないといけないという条件を確認しています(この条件に反するとエラーメッセージが表示されることを確認してみましょう).

このままtemplate(detail.html)を変更しなくてもページは表示されますが,New optionがYour choiceよりも上に表示されてしまうので「下のボックスに記入してください」というメッセージと整合していません.これを修正するには,templateを次のように書き換えればいいでしょう.

<h1>{{ question.question_text }}</h1>
{{ form.non_field_errors }}
<form action="{% url 'polls:detail' question.id %}" method="post">
{% csrf_token %}
<p>
    {{ form.your_choice.label }}<br>
    {{ form.your_choice }}
</p>
<p>
    {{ form.new_option.label }}<br>
    {{ form.new_option }}
</p>
<input type="submit" value="Vote">
</form>

この例のように,フォームの表示の仕方はtemplateの中でfield別に指定することができます(し,labelでfield名を取り出すこともできます).

おわりに

以上で5回目は終了です.第6回に続きます.

7
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?