Djangoのフォームまとめー
2015-06-08

今回は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=)}]

>>> # もう一度作成してみる
>>> 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引数にモデルインスタンスを指定します。

後の例で使用します。

フィールド

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

フィールドは種類が多く説明するのは厳しいので、ドキュメントを参考にしてください。

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

引数名 デフォルト値 説明
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" />'

その他の組み込みウィジェットはリファレンス を参照ください。

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をつかってビューを定義してみます。

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/ にアクセスしただけでログインするようにしました。

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,
}

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ですね。

フォームセット

フォームセットはフォームの繰り返しを表現します。

クラスの生成に formset_factory 関数を使います。これがフォームセットの肝です。

試しに最初に作ったユーザ登録用のフォーム「RegistrationForm」を使います。

>>> from django.forms.formsets import formset_factory
>>> # 空フォーム数の繰り返し回数をextraで指定できる
>>> FormSet = formset_factory(RegistrationForm, extra=3)
>>> # formsetに対する初期データはinitial引数に配列で指定する(ここで指定したフォームはextraとは別に作成される点に注意)
>>> formset1 = FormSet(initial=[{'username': 'test'}, {'username': 'test2'}])

>>> # forms属性にフォームインスタンスが入っている
>>> formset1.forms
[
 <RegistrationForm bound=False, valid=Unknown, fields=(username;email;password)>, <RegistrationForm bound=False, valid=Unknown, fields=(username;email;password)>,
 <RegistrationForm bound=False, valid=Unknown, fields=(username;email;password)>, <RegistrationForm bound=False, valid=Unknown, fields=(username;email;password)>,
 <RegistrationForm bound=False, valid=Unknown, fields=(username;email;password)>
]
>>> len(formset1.forms)
5

>>> # フォームと同じようにHTML出力できる
>>> print(formset1.as_ul())
<input id="id_form-TOTAL_FORMS" name="form-TOTAL_FORMS" type="hidden" value="5" /><input id="id_form-INITIAL_FORMS" name="form-INITIAL_FORMS" type="hidden" value="2" /><input id="id_form-MIN_NUM_FORMS" name="form-MIN_NUM_FORMS" type="hidden" value="0" /><input id="id_form-MAX_NUM_FORMS" name="form-MAX_NUM_FORMS" type="hidden" value="1000" />
<li><label for="id_form-0-username">Username:</label> <input id="id_form-0-username" name="form-0-username" type="text" value="test" /></li>
<li><label for="id_form-0-email">Email:</label> <input id="id_form-0-email" name="form-0-email" type="email" /></li>
<li><label for="id_form-0-password">Password:</label> <input id="id_form-0-password" name="form-0-password" type="password" /></li> <li><label for="id_form-1-username">Username:</label> <input id="id_form-1-username" name="form-1-username" type="text" value="test2" /></li>
<li><label for="id_form-1-email">Email:</label> <input id="id_form-1-email" name="form-1-email" type="email" /></li>
<li><label for="id_form-1-password">Password:</label> <input id="id_form-1-password" name="form-1-password" type="password" /></li> <li><label for="id_form-2-username">Username:</label> <input id="id_form-2-username" name="form-2-username" type="text" /></li>
<li><label for="id_form-2-email">Email:</label> <input id="id_form-2-email" name="form-2-email" type="email" /></li>
<li><label for="id_form-2-password">Password:</label> <input id="id_form-2-password" name="form-2-password" type="password" /></li> <li><label for="id_form-3-username">Username:</label> <input id="id_form-3-username" name="form-3-username" type="text" /></li>
<li><label for="id_form-3-email">Email:</label> <input id="id_form-3-email" name="form-3-email" type="email" /></li>
<li><label for="id_form-3-password">Password:</label> <input id="id_form-3-password" name="form-3-password" type="password" /></li> <li><label for="id_form-4-username">Username:</label> <input id="id_form-4-username" name="form-4-username" type="text" /></li>
<li><label for="id_form-4-email">Email:</label> <input id="id_form-4-email" name="form-4-email" type="email" /></li>
<li><label for="id_form-4-password">Password:</label> <input id="id_form-4-password" name="form-4-password" type="password" /></li>

フォームセットではパラメータの種類や形式が単体のフォーム出力とは異なることが分かるでしょうか。

自動的に生成されるパラメータを手動検査するのはあまりよい方法とは言えません。

フォームセットによって出力されたパラメータは、フォームセットによってバリデーションするのが妥当でしょう。(タブンネ)

ManagementForm

こういった繰り返し以外の特殊なパラメータを扱うのがManagementFormと呼ばれるものです。

具体的なパラメータは form-TOTAL_FORMS, form-INITIAL_FORMS です。(form-MIN_NUM_FORMSとform-MAX_NUM_FORMSは放置)

それぞれ「総フォーム数」と「初期入力済フォーム数」を示しています。

未入力フォームセットの場合

総フォーム数は3(extra引数で指定した)+2(initial)で5となります。

>>> formset1.total_form_count()
5
>>> formset1.initial_form_count()
2
>>> # management_form属性からも取得できる
>>> formset1.management_form

入力済フォームセットの場合

フォームセットにパラメータを与えた場合、それぞれのフォームインスタンスは入力済フォーム(束縛フォーム)として扱われます。

束縛フォームセットとでも言うのでしょうか。

入力済フォームセットでは繰り返し回数はパラメータ(form-TOTAL_FORMS)によって決定されます。

>>> # 擬似的にform-TOTAL_FORMS=10のパラメータを作成して
>>> qd = QueryDict('form-TOTAL_FORMS=10&form-INITIAL_FORMS=1&form-MIN_NUM_FORMS=0&form-0-username=test0&form-0-email=test0@example.com&form-0-password=testtest&form-1-username=test1&form-1-email=test1@example.com&form-1-password=testtest&form-2-username=test2&form-2-email=test2@example.com&form-2-password=testtest', mutable=True)

>>> # formset2にパラメータを与えてみる
>>> formset2 = FormSet(qd)
>>> formset2.total_form_count()
10
>>> formset2.initial_form_count()
1
>>> # フォーム10個分のパラメータはないけどバリデーションは通る
>>> formset2.is_valid()
True
>>> # 10個ともvalid=Trueとなる(ここがよくわからん)
>>> formset2.forms
[
 <RegistrationForm bound=True, valid=True, fields=(username;email;password)>, <RegistrationForm bound=True, valid=True, fields=(username;email;password)>,
 <RegistrationForm bound=True, valid=True, fields=(username;email;password)>, <RegistrationForm bound=True, valid=True, fields=(username;email;password)>,
 <RegistrationForm bound=True, valid=True, fields=(username;email;password)>, <RegistrationForm bound=True, valid=True, fields=(username;email;password)>,
 <RegistrationForm bound=True, valid=True, fields=(username;email;password)>, <RegistrationForm bound=True, valid=True, fields=(username;email;password)>,
 <RegistrationForm bound=True, valid=True, fields=(username;email;password)>, <RegistrationForm bound=True, valid=True, fields=(username;email;password)>
]
>>> # 入力されたパラメータを取り出すにはフォームと同じようにcleaned_dataとする
>>> formset2.cleaned_data
[
 {'username': u'test0', 'password': u'testtest', 'email': u'test0@example.com'},
 {'username': u'test1', 'password': u'testtest', 'email': u'test1@example.com'},
 {'username': u'test2', 'password': u'testtest', 'email': u'test2@example.com'},
 {}, {}, {}, {}, {}, {}, {}
]

フォームセットのバリデーションはフォーム単位で行われ、パラメータの入力が不十分なフォームが存在する場合バリデーションはエラーとなるようです。

言い換えるとトータルのパラメータ数が足りなくとも、フォーム単位で不整合が生じなければエラーとはなりません。

>>> # 上の続き
>>> # form-2の省略不可パラメータを消してみる
>>> del qd['form-2-email']
>>> formset2 = FormSet(qd)
>>> formset2.is_valid()
False

>>> # エラー内容はerrorsに入ってる
>>> formset2.errors
[{}, {}, {'email': [u'This field is required.']}, {}, {}, {}, {}, {}, {}, {}]

>>> # バリデーションに通らないとcleaned_data属性がとれない
>>> formset2.cleaned_data
Traceback (most recent call last):
File "", line 1, in
    File "/home/user/Tests/djangoform/local/lib/python2.7/site-packages/django/forms/formsets.py", line 197, in cleaned_data
    raise AttributeError("'%s' object has no attribute 'cleaned_data'" % self.__class__.__name__)
    AttributeError: 'RegistrationFormFormSet' object has no attribute 'cleaned_data'

max_num

formset_factory関数のmax_num引数にてフォーム数の上限を設定することができますが、これは空の初期フォーム数の話です。

>>> FormSet = formset_factory(RegistrationForm, extra=3, max_num=1)
>>> formset3 = FormSet(initial=[{'username': 'test'}, {'username': 'test2'}])
>>> formset3.total_form_count()
2
>>> formset3.initial_form_count()
2
>>> print(formset3.management_form)


>>> formset4 = FormSet(qd)
>>> formset4.total_form_count()
10
>>> formset4.initial_form_count()
1

初期値があるフォームとユーザが入力したフォームの数はmax_num引数では制限できません。パラメータでも指定できるし、これ何に使えるんですかね?(困惑) 入力フォーム数を制限したい場合はユーザの入力値(form-TOTAL_FORMS)を検査するか、単純にループ数に制限をかける等の対策をするのがいいかもしれませんね。

can_order

パラメータの入力順を制御するのに利用します。 formset_factory関数のcan_order引数に「True」を指定することでHTMLに出力され、パラメータとして受け取ることが可能となります。 デフォルトはFalseです。

>>> FormSet = formset_factory(RegistrationForm, extra=3, can_order=True)
>>> formset5 = FormSet()
>>> # ORDERパラメータが追加された
>>> print(formset5)
<input id="id_form-TOTAL_FORMS" name="form-TOTAL_FORMS" type="hidden" value="3" /><input id="id_form-INITIAL_FORMS" name="form-INITIAL_FORMS" type="hidden" value="0" /><input id="id_form-MIN_NUM_FORMS" name="form-MIN_NUM_FORMS" type="hidden" value="0" /><input id="id_form-MAX_NUM_FORMS" name="form-MAX_NUM_FORMS" type="hidden" value="1000" />
<tr><th><label for="id_form-0-username">Username:</label></th><td><input id="id_form-0-username" name="form-0-username" type="text" /></td></tr>
<tr><th><label for="id_form-0-email">Email:</label></th><td><input id="id_form-0-email" name="form-0-email" type="email" /></td></tr>
<tr><th><label for="id_form-0-password">Password:</label></th><td><input id="id_form-0-password" name="form-0-password" type="password" /></td></tr>
<tr><th><label for="id_form-0-ORDER">Order:</label></th><td><input id="id_form-0-ORDER" name="form-0-ORDER" type="number" /></td></tr> <tr><th><label for="id_form-1-username">Username:</label></th><td><input id="id_form-1-username" name="form-1-username" type="text" /></td></tr>
<tr><th><label for="id_form-1-email">Email:</label></th><td><input id="id_form-1-email" name="form-1-email" type="email" /></td></tr>
<tr><th><label for="id_form-1-password">Password:</label></th><td><input id="id_form-1-password" name="form-1-password" type="password" /></td></tr>
<tr><th><label for="id_form-1-ORDER">Order:</label></th><td><input id="id_form-1-ORDER" name="form-1-ORDER" type="number" /></td></tr> <tr><th><label for="id_form-2-username">Username:</label></th><td><input id="id_form-2-username" name="form-2-username" type="text" /></td></tr>
<tr><th><label for="id_form-2-email">Email:</label></th><td><input id="id_form-2-email" name="form-2-email" type="email" /></td></tr>
<tr><th><label for="id_form-2-password">Password:</label></th><td><input id="id_form-2-password" name="form-2-password" type="password" /></td></tr>
<tr><th><label for="id_form-2-ORDER">Order:</label></th><td><input id="id_form-2-ORDER" name="form-2-ORDER" type="number" /></td></tr>


>>> # パラメータにORDERを加える
>>> # form-1,form-2のORDERを指定する。わざとform-0は指定しない
>>> qd.update({'form-1-ORDER': 1, 'form-2-ORDER': 0})
>>> formset6 = FormSet(qd)

>>> # cleaned_dataは整列されていない
>>> formset6.cleaned_data
[{'username': u'test0', 'password': u'testtest', 'email': u'test0@example.com', u'ORDER': None}, {'username': u'test1', 'password': u'testtest', 'email': u'test1@example.com', u'ORDER': 1}, {'username': u'test2', 'password': u'testtest', 'email': u'test2@example.com', u'ORDER': 0}]

>>> # formsも整列されていない
>>> for form in formset6.forms:
...     form.cleaned_data
...
{'username': u'test0', 'password': u'testtest', 'email': u'test0@example.com', u'ORDER': None}
{'username': u'test1', 'password': u'testtest', 'email': u'test1@example.com', u'ORDER': 1}
{'username': u'test2', 'password': u'testtest', 'email': u'test2@example.com', u'ORDER': 0}

>>> # 整列されたformはordered_formsに入っている
>>> for form in formset6.ordered_forms:
...     form.cleaned_data
...
{'username': u'test2', 'password': u'testtest', 'email': u'test2@example.com', u'ORDER': 0}
{'username': u'test1', 'password': u'testtest', 'email': u'test1@example.com', u'ORDER': 1}
{'username': u'test0', 'password': u'testtest', 'email': u'test0@example.com', u'ORDER': None}

指定されない場合、ORDERはNoneとなり順番は最後となるようです。

can_delete

削除対象の入力値を受け取るのに利用します。 formset_factory関数のcan_order引数に「True」を指定することでHTMLに出力され、パラメータとして受け取ることが可能となります。 デフォルトはFalseです。

>>> FormSet = formset_factory(RegistrationForm, extra=3, can_delete=True)
>>> formset7 = FormSet()

>>> # 削除フラグはチェックボックスとして出力される
>>> print(formset7)
<input id="id_form-TOTAL_FORMS" name="form-TOTAL_FORMS" type="hidden" value="3" /><input id="id_form-INITIAL_FORMS" name="form-INITIAL_FORMS" type="hidden" value="0" /><input id="id_form-MIN_NUM_FORMS" name="form-MIN_NUM_FORMS" type="hidden" value="0" /><input id="id_form-MAX_NUM_FORMS" name="form-MAX_NUM_FORMS" type="hidden" value="1000" />
<tr><th><label for="id_form-0-username">Username:</label></th><td><input id="id_form-0-username" name="form-0-username" type="text" /></td></tr>
<tr><th><label for="id_form-0-email">Email:</label></th><td><input id="id_form-0-email" name="form-0-email" type="email" /></td></tr>
<tr><th><label for="id_form-0-password">Password:</label></th><td><input id="id_form-0-password" name="form-0-password" type="password" /></td></tr>
<tr><th><label for="id_form-0-DELETE">Delete:</label></th><td><input id="id_form-0-DELETE" name="form-0-DELETE" type="checkbox" /></td></tr> <tr><th><label for="id_form-1-username">Username:</label></th><td><input id="id_form-1-username" name="form-1-username" type="text" /></td></tr>
<tr><th><label for="id_form-1-email">Email:</label></th><td><input id="id_form-1-email" name="form-1-email" type="email" /></td></tr>
<tr><th><label for="id_form-1-password">Password:</label></th><td><input id="id_form-1-password" name="form-1-password" type="password" /></td></tr>
<tr><th><label for="id_form-1-DELETE">Delete:</label></th><td><input id="id_form-1-DELETE" name="form-1-DELETE" type="checkbox" /></td></tr> <tr><th><label for="id_form-2-username">Username:</label></th><td><input id="id_form-2-username" name="form-2-username" type="text" /></td></tr>
<tr><th><label for="id_form-2-email">Email:</label></th><td><input id="id_form-2-email" name="form-2-email" type="email" /></td></tr>
<tr><th><label for="id_form-2-password">Password:</label></th><td><input id="id_form-2-password" name="form-2-password" type="password" /></td></tr>
<tr><th><label for="id_form-2-DELETE">Delete:</label></th><td><input id="id_form-2-DELETE" name="form-2-DELETE" type="checkbox" /></td></tr>

>>> # form-0,form-1に値を入れてみる。form-0には文字列の0を指定してみた
>>> qd.update({'form-0-DELETE': '0', 'form-1-DELETE': '1'})
>>> formset8 = FormSet(qd)
>>> formset8.cleaned_data
[
 {'username': u'test0', 'password': u'testtest', 'email': u'test0@example.com', u'DELETE': True},
 {'username': u'test1', 'password': u'testtest', 'email': u'test1@example.com', u'DELETE': True},
 {'username': u'test2', 'password': u'testtest', 'email': u'test2@example.com', u'DELETE': False}
]

>>> # 削除対象のフォームはdeleted_formsに入ってる。あとは煮るなり焼くなり
>>> for form in formset8.deleted_forms:
...     form.cleaned_data

{'username': u'test0', 'password': u'testtest', 'email': u'test0@example.com', u'DELETE': True}
{'username': u'test1', 'password': u'testtest', 'email': u'test1@example.com', u'DELETE': True}

文字列の '0' はTrueと判断されるため、削除対象となったようです。

数値型の 0 オブジェクトを指定するとdeleted_formsには入りません。

通常チェックボックスであれば指定しないとDELETEパラメータ自体がとばないはずですが、 ひねくれた使い方をするときは気をつけましょう

フォームセットのカスタマイズ

フォームセットの挙動を変更したい場合、「BaseFormSet」を継承したクラスを「formset_factory」のformset引数に与えます。

>>> from django.forms.formsets import BaseFormSet
>>> class BaseRegistrationFormSet(BaseFormSet):
...     def add_fields(self, form, index):
...         """フォームセットの時だけreasonフィールドを追加する(ユーザを追加した理由というどうでもいい設定)
...         """
...         super(BaseRegistrationFormSet, self).add_fields(form, index)
...         form.fields["reason"] = forms.CharField()
...
...     def clean(self):
...         """重複したusernameがないことを確認するバリデータ"""
...         if any(self.errors):
...             # いずれかのフォームセットでバリデーションエラーがあった場合、フォームセットのバリデーションは行わない
...             return
...
...         names = []
...         for i in range(self.total_form_count()):
...             form = self.forms[i]
...             name = form.cleaned_data['username']
...             if name in names:
...                 raise forms.ValidationError(u'ユーザ名が重複しています')
...             names.append(name)

>>> FormSet = formset_factory(RegistrationForm, formset=BaseRegistrationFormSet)
>>> qd = QueryDict('form-TOTAL_FORMS=3&form-INITIAL_FORMS=1&form-MIN_NUM_FORMS=0&form-0-reason=test0&form-0-username=test0&form-0-email=test0@example.com&form-0-password=testtest&form-1-reason=test1&form-1-username=test1&form-1-email=test1@example.com&form-1-password=testtest&form-2-reason=test1&form-2-username=test2&form-2-email=test2@example.com&form-2-password=testtest', mutable=True)
>>> formset = FormSet(qd)
>>> formset.is_valid()
True

>>> # form-2のreasonパラメータ(省略不可)を消す
>>> qd2 = qd.copy()
>>> del qd2['form-2-reason']
>>> formset2 = FormSet(qd2)
>>> formset2.is_valid()
False

>>> # わざとユーザIDを重複させる
>>> qd3 = qd.copy()
>>> qd3['form-1-username'] = 'test0'
>>> formset3 = FormSet(qd3)
>>> formset3.is_valid()
False

応用してなにかやろうかと思ったけど、もう触りたくないのでこれで終わり。