Djangoのフォームまとめー

今回はDjangoのフォームについて書きました。
だらだらしてたらアップするのがかなり遅れました(ごめんね)

難しいですよねフォーム。正直すこし苦手です。この記事はそんな人向けの備忘録(?)です。

Djangoのバージョンは1.8を使っています。なお、確認のために使う対話モードはpython-shellではなくdjango-shellであることに注意してください。

Form

Formはバリデータであり、HTMLのFORM関連要素を表現します。

>>> from django import forms
>>> class RegistrationForm(forms.Form):
...     username = forms.CharField(required=False)
...     email = forms.EmailField(required=True)
...     password = forms.CharField(widget=forms.PasswordInput(), min_length=8)
 
>>> from django.http import QueryDict
>>> querydict = QueryDict('username=test&email=test@example.com&password=testtest', mutable=True)
>>> form1 = RegistrationForm(querydict)
>>> form1.is_valid()
True
 
>>> querydict['password'] = 'test'
>>> form2 = RegistrationForm(querydict)
>>> form2.is_valid()
False
>>> # cleaned_dataにはバリデーションを通過した値のみが入っている
>>> form2.cleaned_data
{'username': u'test', 'email': u'test@example.com'}

続いてHTMLとして出力されることを確認してみます。

>>> # パラメータと結びついていないフォームを作成
>>> form = RegistrationForm()
 
>>> # パラメータが未入力のフォームはis_boundがFalseになる
>>> form.is_bound
False
 
>>> # 表示すると未入力のHTMLフォーム要素が出力される
>>> print(form)
print(form)
<tr><th><label for="id_username">Username:</label></th><td><input id="id_username" name="username" type="text" /></td></tr>
<tr><th><label for="id_email">Email:</label></th><td><input id="id_email" name="email" type="email" /></td></tr>
<tr><th><label for="id_password">Password:</label></th><td><input id="id_password" name="password" type="password" /></td></tr>
 
>>> # パラメータと結びついているフォームを表示すると入力済みHTMLフォーム要素が出力される
>>> print(form1)
<tr><th><label for="id_username">Username:</label></th><td><input id="id_username" name="username" type="text" value="test" /></td></tr>
<tr><th><label for="id_email">Email:</label></th><td><input id="id_email" name="email" type="email" value="test@example.com" /></td></tr>
<tr><th><label for="id_password">Password:</label></th><td><input id="id_password" name="password" type="password" /></td></tr>

ちなみにパラメータと紐付いたフォームを束縛フォーム(bound form)と呼ぶようです。
最初に訳した人はあとで職員室まで来なさい。

フィールドを動的に追加する

フォームのフィールドは「fields」属性に辞書形式で管理されているため、追加、削除、変更は割と簡単にできます。
通常は__init__の中で行います。必要に応じて以下のように引数を受け取るように変更してもよいでしょう。
その場合、後述するFormViewやFormSetといった関連機能と連動させる際に引数を引き渡す処理を別途用意する必要があるという点に注意してください。

>>> from django import forms
>>> class RegistrationForm2(forms.Form):
...     user_type = forms.ChoiceField(label=u'会員タイプ', choices=[], widget=forms.RadioSelect())
...
...     def __init__(self, gender, **kwargs):
...         """性別に応じて選択できる会員タイプを変えるという設定
...         """
...         super(RegistrationForm2, self).__init__(**kwargs)
...         if gender == 'male':
...             self.fields['user_type'].choices = [('a', 'A'), ('b', 'B')]
...         else:
...             self.fields['user_type'].choices = [('c', 'C'), ('d', 'D'), ('e', 'E')]
...             # ついでに性別が男性以外ならメールマガジンを購読するかどうかも聞くことにしよう
...             self.fields['mail_magazine'] = forms.BooleanField(label=u'メルマガ購読', widget=forms.CheckboxInput())
 
>>> # 男性会員(会員タイプA,Bだけが出力されている)
>>> form = RegistrationForm2(gender='male')
>>> print(form)
<tr><th><label for="id_user_type_0">会員タイプ:</label></th><td><ul id="id_user_type"><li><label for="id_user_type_0"><input id="id_user_type_0" name="user_type" type="radio" value="a" /> A</label></li>
<li><label for="id_user_type_1"><input id="id_user_type_1" name="user_type" type="radio" value="b" /> B</label></li></ul></td></tr>
 
>>> # 女性会員(会員タイプC,Dだけが出力されていて、メルマガ購読のチェックボックスも出力されている)
>>> form = RegistrationForm2(gender='female')
>>> print(form)
<tr><th><label for="id_user_type_0">会員タイプ:</label></th><td><ul id="id_user_type"><li><label for="id_user_type_0"><input id="id_user_type_0" name="user_type" type="radio" value="c" /> C</label></li>
<li><label for="id_user_type_1"><input id="id_user_type_1" name="user_type" type="radio" value="d" /> D</label></li>
<li><label for="id_user_type_2"><input id="id_user_type_2" name="user_type" type="radio" value="e" /> E</label></li></ul></td></tr>
<tr><th><label for="id_mail_magazine">メールマガジン購読:</label></th><td><input id="id_mail_magazine" name="mail_magazine" type="checkbox" /></td></tr>

普通性別もパラメータで受け取りそうだけど、ただの例だから許してほしいにゃん

バリデーションエラー出力について

パラメータ(入力値)が期待に沿っていないとバリデーションエラーが発生します。
エラーメッセージはHTML形式やテキスト形式など複数用意されています。

>>> # 不正なパラメータに結びついたフォームを表示すると入力済みのHTMLフォームと共にエラーメッセージが出力される
>>> print(form2)
<tr><th><label for="id_username">Username:</label></th><td><input id="id_username" name="username" type="text" value="test" /></td></tr>
<tr><th><label for="id_email">Email:</label></th><td><input id="id_email" name="email" type="email" value="test@example.com" /></td></tr>
<tr><th><label for="id_password">Password:</label></th><td><ul class="errorlist"><li>Ensure this value has at least 8 characters (it has 4).</li></ul><input id="id_password" name="password" type="password" /></td></tr>
 
>>> # デフォルトはHTMLでの出力
>>> print(form2.errors)
<ul class="errorlist"><li>password<ul class="errorlist"><li>Ensure this value has at least 8 characters (it has 4).</li></ul></li></ul>
 
>>> # デフォルトの形式はas_ul()
>>> str(form2.errors) == form2.errors.as_ul()
True
 
>>> # JSON形式の出力
>>> str(form2.errors.as_json())
'{"password": [{"message": "Ensure this value has at least 8 characters (it has 4).", "code": "min_length"}]}'
 
>>> # オブジェクト形式の出力(Django1.7から)
>>> form2.errors.as_data()
{'password': [ValidationError([u'Ensure this value has at least 8 characters (it has 4).'])]}
 
>>> # テキスト形式の出力
>>> form2.errors.as_text()
u'* password\n  * Ensure this value has at least 8 characters (it has 4).'

clean

djangoフォームのカスタムバリデータにあたる機能です。

「clean_<フィールド名>」メソッドと「clean」メソッドがあり、前者はフィールド別バリデータです。
後者はバリデーションが複数のフィールドに依存する場合に使います。フィールド別バリデータよりも後に呼び出されます。
また、フィールド別バリデータがフィールド値を返すのに対し、cleanメソッドはcleaned_dataを返却することにも注意してください。

>>> from django import forms
>>> class RegistrationForm(forms.Form):
...     username = forms.CharField(required=False)
...     email = forms.EmailField(required=True)
...     password = forms.CharField(widget=forms.PasswordInput(), min_length=8)
...
...     def clean(self):
...         """メールアドレスのユーザ部分がusernameになっていることを確認するバリデーション"""
...         cd = self.cleaned_data
...         if not cd['email'].startswith(cd['username']):
...             raise forms.ValidationError('メールアドレスが不正です')
...         # 返却値はcleaned_data
...         return cd
...
...     def clean_password(self):
...         """パスワードには3種類以上の文字が使われていることを確認する"""
...         password = self.cleaned_data['password']
...         if len(set(password)) < 3:
...             raise forms.ValidationError('パスワードの強度に問題があります')
...         # 返却値はフィールド値
...         return password
 
>>> from django.http import QueryDict
>>> qd = QueryDict('username=test&email=test@example.com&password=testtest', mutable=True)
>>>
>>> form = RegistrationForm(qd)
>>> form.is_valid()
True
 
>>> qd['username'] = 'aaaa'
>>> form = RegistrationForm(qd)
>>> form.is_valid()
False
>>> print(form.errors)
<ul class="errorlist"><li>__all__<ul class="errorlist nonfield"><li>メールアドレスが不正です</li></ul></li></ul>
 
>>> qd['username'] = 'test'
>>> qd['password'] = 'abababab'
>>> form = RegistrationForm(qd)
>>> form.is_valid()
False
>>> print(form.errors)
<ul class="errorlist"><li>password<ul class="errorlist"><li>パスワードの強度に問題があります</li></ul></li></ul>

cleanはバリデータであるとともに返却値を自在に変更できます。要件に応じてうまく利用しましょう。

ModelForm

特定のModel要素と結びついたFormです。モデルをマッピングし、フォームのフィールドを自動的に定義してくれます。

前回作ったRegistrationFormをModelFormを使って表現してみます。

>>> from django import forms
>>> from django.contrib.auth.models import User
 
>>> class RegistrationForm2(forms.ModelForm):
...     class Meta:
...         model = User
...         fields = ('username', 'email', 'password')

Metaクラスのmodel属性にモデルクラスを指定します。
fieldには表示したい列を指定します。すべて表示したいときは文字列で「__all__」とします。
逆に表示したくない列だけを指定するときはexclude属性を使います。
Django1.8からどちらかの属性の指定が必須になったようです(1.7ではWarningがでる)

通常のFormに加え、与えられたパラメータを使いレコードを操作したり、各列の制約に合わせたバリデーションを行うことができます。
列の制約によってはバリデーション時に勝手にクエリが発生する可能性があるという点に注意してください。

パラメータと紐付いたUserモデルを使ってユーザを作成してみます。

>>> form = RegistrationForm2(querydict)
>>> form.is_valid()
True
>>> user = form.save(commit=False)
>>> user.set_password(form.cleaned_data['password'])
>>> user.save()
 
>>> # ユーザが作成されていることを確認
>>> User.objects.values()
[
 {'username': u'test',
  'first_name': u'',
  'last_name': u'',
  'is_active': True,
  'email': u'test@example.com',
  'is_superuser': False,
  'is_staff': False,
  'last_login': None,
  'password': u'pbkdf2_sha256$20000$FBwoaDWZJGAt$4utEBBWfn0GR2ewLpzrNt0lj2N9Dr8X4GP1VrcPf3S0=',
  'id': 1,
  'date_joined': datetime.datetime(2015, 6, 8, 12, 0, 0, 0, tzinfo=<UTC>)}]
 
>>> # もう一度作成してみる
>>> form2 = RegistrationForm2(querydict)
>>> form2.is_valid()
False
>>> # 今度はusername列の一意制約に反したためエラーが発生
>>> form2.errors
{'username': [u'A user with that username already exists.']}

form.save()とすることで紐づくModelインスタンスが保存されますが、Userモデルのようにそのまま保存するわけにはいかない列が含まれているときは、commit=Falseと指定することで、保存前のModelインスタンスを取得できます。

上記ではレコードの新規作成(insert)でしたが、既存のモデルインスタンスを更新(update)したい場合はフォームインスタンス作成時、instance引数にモデルインスタンスを指定します。後の例で使用します。

フィールド

フィールドには期待するリクエストパラメータの型にあったフィールドインスタンスを指定します。

フィールドは種類が多く説明するのは厳しいので、ドキュメントを参考にしてください。
Django 1.4 Form Fields(古いけど日本語だよ!)
Django 1.8 Form Fields(新しいけど英語だよ!)

各フィールドの引数形式は多少異なりますが、以下の引数は共通して指定可能です。

引数名 デフォルト値 説明
required True パラメータの指定が必須かどうかを判断するフラグ
label None ユーザに対して表示するためのフィールド名。省略すると属性名の頭文字を大文字にしたものが使われる
initial None 表示の際の初期値。既にパラメータと結びついた状態では表示されない。
help_text フィールドの説明
widget None フィールドの装飾(後述します)
error_messages None フィールドの入力エラーメッセージ
validators [] フィールドに対するバリデーション処理(関数)を複数指定する。関数はバリデーション対象の値として引数を1つ受け取る
label_suffix ‘:’ ラベル名の後ろに表示される文字列
has_changed() - 初期値から変更されたかどうかを判定するメソッド

また、フィールドもcleanメソッドを持っており、is_validメソッドやerrorsプロパティから各フィールドのcleanメソッドを呼び出しているというわけです。
DateTimeField単体でクリーンしてみます。

>>> from django import forms
>>> # input_formatsに受け入れるフォーマットを複数指定できる※省略不可
>>> f = forms.DateTimeField(input_formats=['%Y-%m-%d %H:%M'])
>>> f.clean('2015-05-22 10:10')
datetime.datetime(2015, 5, 22, 10, 10, tzinfo=<django.utils.timezone.LocalTimezone object at 0x7f9a81db58d0>)

ウィジェット

ウィジェットによってフィールドの見た目を変更できます。
Fieldクラスのwidget引数に指定します。
nameというパラメータをinputタグのtype=hiddenで引き渡したい場合、フィールドは以下のようにします。

>>> from django import forms
>>> name = forms.CharField(widget=forms.HiddenInput)

class属性などを指定したい場合はattrs引数を指定します。
わかりづらいのでWidgetクラス単体で動作を確認してみましょう。

>>> hi = forms.HiddenInput(attrs={'class': 'param', 'id': 'param-name'})
>>> hi.render('name', 'taro')
u'<input class="param" id="param-name" name="name" type="hidden" value="taro" />'
 
>>> # 重複属性を指定した場合は無視されるので注意
>>> hi = forms.HiddenInput(attrs={'type': 'text', 'name': 'jiro'})
>>> hi.render('name', 'taro')
u'<input name="name" type="hidden" value="taro" />'

その他の組み込みウィジェットはリファレンス 1.4 か 1.8を見てください。

FormView

ジェネリックビューの一つにFormViewというものがあります。(ジェネリックビューについて知らない方はリンク先を参照してください)

FormViewはFormに紐づきます。基本はTemplateViewと同じですが、自動的にバリデーションが行われformインスタンスがコンテキストに引き継がれるという点が異なります。

では前のModelFormも応用してパスワード変更フォームを作ってみます。要件は以下のようにしてみます。

  • 現在のパスワードを入力させ、一致するかを確認する
  • 新パスワードは2回入力させ、両方の値が等しいこと
  • 新パスワードは8文字以上であること
  • 現在と同じパスワードを新パスワードにすることはできない
  • パスワード変更に成功するとマイページに遷移する
  • パスワード変更に失敗するとパスワード変更ページに戻る

account/settings.py

デフォルトのエラーメッセージを日本語で表示したいのでLANGUAGE_CODEを設定します。
自分で用意した場合は別途、国際化が必要です。

LANGUAGE_CODE = 'ja'

forms.py

パスワード更新用にフォームを作成します。

from django import forms
from django.contrib.auth.models import User
 
class PasswordForm(forms.ModelForm):
    class Meta:
        model = User
        fields = ()
 
    current_password = forms.CharField(widget=forms.PasswordInput)
    new_password1 = forms.CharField(widget=forms.PasswordInput, min_length=8)
    new_password2 = forms.CharField(widget=forms.PasswordInput, min_length=8)
 
    def clean_current_password(self):
        current_password = self.data.get('current_password')
        if not self.instance.check_password(current_password):
            raise forms.ValidationError('現在のパスワードが違います')
        return current_password
 
    def clean_new_password1(self):
        new_password1 = self.data.get('new_password1')
        new_password2 = self.data.get('new_password2')
        if new_password1 != new_password2:
            raise forms.ValidationError('新しいパスワードが一致しません')
 
        if self.instance.check_password(new_password1):
            raise forms.ValidationError('新パスワードは旧パスワードと違うものにしてください')
        return new_password1

views.py

メインとなるFormViewをつかってビューを定義してみます。
FormViewで特殊な意味を持つ属性は以下のとおりです。

form_class 利用するフォームクラス
template_name 表示するテンプレート
success_url バリデーション終了後に遷移する画面のURL
form_valid バリデーション成功時に実行する処理
form_invalid バリデーション失敗時に実行する処理
get_context_data テンプレートレンダリング時に利用するコンテキスト
get_form formインスタンスの生成。__init__をカスタマイズして引数を増やしている場合は、ここをいじる必要がありそう(未確認)
get_form_class 対象のフォームクラスを取得するメソッド
get_form_kwargs フォームインスタンス生成時に与えるキーワード引数。デフォルトでは「{‘initial': self.get_initial(), ‘prefix': self.get_prefix(), ‘data': self.request.POST, ‘files': self.request.FILES}」が使われるようです
from django.utils.decorators import method_decorator
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.views.generic import FormView
from forms import PasswordForm
 
 
class PasswordView(FormView):
    form_class = PasswordForm
    template_name = 'password.html'
    success_url = '/mypage/'
 
    def form_valid(self, form):
        form.instance.set_password(form.cleaned_data['new_password1'])
        return super(PasswordView, self).form_valid(form)
 
    def get_form_kwargs(self):
        kwargs = super(PasswordView, self).get_form_kwargs()
        # ModelFormにinstance引数を与える
        # このタイミングだとself.userがないためAttributeErrorになる
        kwargs['instance'] = self.request.user
        return kwargs
 
    @method_decorator(login_required)
    def dispatch(self, *args, **kwargs):
        return super(PasswordView, self).dispatch(*args, **kwargs)
 
 
def login(request):
    """強制的にtest(1)ユーザでログインさせる"""
    from django.contrib.auth import authenticate, login as _login
    user = authenticate(username='test', password='testtest')
    _login(request, user)
    return HttpResponse('logined')
 
 
def mypage(request):
    """mypageと表示するだけ"""
    return HttpResponse('mypage')

ログイン画面を作るのが面倒だったので、/login/にアクセスしただけでログインするようにしました。

urls.py

ルーティング設定を行います。
関数型ビューの場合はそのままかモジュールパスを指定し、ジェネリックビューの場合はas_viewメソッドの指定します。この辺について詳しく知りたい方はURLルーティングやジェネリックビューのリファレンスを参照してください。

from django.conf.urls import include, url
from views import login, mypage, PasswordView
 
urlpatterns = [
    url(r'login/', login),
    url(r'mypage/', mypage),
    url(r'password/', PasswordView.as_view()),
]

templates/password.html

パスワード変更フォームのテンプレートです。
FormViewを使うとフォームインスタンスが「form」というコンテキストで渡されます。

<html>
<form method="post">{% csrf_token %}
{{form.as_p}}
<input type="submit" />
</form>
</html>
入力前 未入力の状態で送信
djangoform01 djangoform02
新パスワードが8文字以下 新パスワードが変わっていない
djangoform03 djangoform04
(※メッセージを間違えました:正しくは「新パスワードは旧パスワードと違うものにしてください」です)
確認用パスワードが一致しない パスワード更新成功
djangoform05 djangoform00

結果上記のようになりました。洗練されたUIですね。

次はフォームセットですが、長くなったのでページを分割します。

1 2