LoginSignup
38
37

More than 3 years have passed since last update.

Django Formsetを使ってFormをたくさん並べよう!

Posted at

環境

Django2.2

まえがき

Formsets | Django documentation | Djangoの内容から、formsetを使用する上で最低限必要な内容+実際に使うときの注意点をまとめた記事です。

またmodelformsetの公式リンクはCreating forms from models | Django documentation | Djangoです。本記事で使用しているコードの一部は、上記リンクを参考に、変更を加えたものです。

私自身初学者ですので、対象となる読者も初心者となります。Djangoのチュートリアルは理解していて、通常のformの挙動は理解しているくらいの読者を想定しています。 中級者には回りくどく感じると思います。また、省略可能な引数名をなるべく省略せずに書いています。可読性と説明の都合です。

GitHubに、実際に動くProjectの形でコードを上げたので、よろしければパラメータを変えて遊んでみてください。SECRET_KYEは抜いてあるのでご注意ください。

筆者はWeb開発初心者ですので、間違いや不足点などあればご指摘いただけると幸いです。

Formsetとは

同じ種類のFormが複数並んでいるものです。全てのフォームに入力してから一気にsubmitすることができます。下の図では、データベース上の画像に対してラジオボタンを表示し、ラベルを振る例を示しています。

formset1.png

並べるformが2~3個ならば,prefixを利用(後述)しても可能ですが、formsetを利用すればコードも綺麗になり、選択肢も増えるので推奨します。

Formset: 実際の使い方

通常のformの使い方を理解しているならば、同じようにformsetも利用できます。通常のformクラスから、formset_factory関数を使ってformsetクラスを作成できます。formsetはformと同じように扱うことができます。

以下、ソースコードの後に解説を載せています。

formset_factory

単一種類のformを複数個表示する際に利用します。

models.py
class TestForm(forms.Form):
    title = forms.CharField(max_length=20)
    date = forms.DateField()
  • 上記が使用するFormです。


first/form1.html
<form method="post">
    {% csrf_token %}
    {{ formset }}
    <button type="submit">submit</button>
</form>
  • 次に示すviews.pyで使っているtemplateの該当部分です。


views.py
from django import forms
from first.forms import TestForm
from django.http import HttpResponse


def make_test_formset(request):
    TestFormSet = forms.formset_factory(
            form=TestForm,
            extra=3,     # default-> 1
            max_num=4    # initial含めformは最大4となる
    )
    # 通常のformと同様に処理できる。
    if request.method == 'POST':
        formset = TestFormSet(request.POST)
        if formset.is_valid():
            # 参考として,cleaned_dataの中身を表示してみます。          
            data = repr(formset.cleaned_data)
            return HttpResponse(data) 
    else:
        formset = TestFormSet() # initialを渡すことができます。
        # formset = TestFormSet(initial=[{'title':'abc', 'date': '2019-01-01'},])
    return render(request, 'first/form1.html', {'formset': formset})

上記views.pyのポイントは以下の通りです。

  • 通常のformとviewsの書き方はほとんど変わりません。
  • formset.cleaned_dataに入力された値が入ります。本コードでの実際の結果を示すと以下のように,listに各Formのデータがmappingの形で順番に入ります。イテレーションするなりして利用してください。
[{'title': 'No1', 'date': datetime.date(2019, 1, 1)},
{'title': 'No2', 'date': datetime.date(2019, 1, 2)}, 
{'title': 'asdf', 'date': datetime.date(2019, 2, 2)}, 
{'title': 'asdfdsfadsfaddsf', 'date': datetime.date(2000, 2, 2)}]
  • 上記のviews.pyでforms.formset_factory関数は、TestFormと名付けたformクラスからformsetを作成しています。この際重要になるのはextraとmax_numです。これらは表示されるformの個数を指定するために使います。
  • extraは「空のformの個数」で、max_numは「トータルのformの個数」です。formにはinitial引数により、あらかじめ値が入力されたformを指定することができます(データのformへのbindについてはThe Forms API | Django documentation | Djangoを確認してください。簡単に言うと、formに初期値として表示したい値はinitialで渡す必要があり、またGET時だけでなくPOST時にも渡してください)。その際の挙動を制御するため、extraとmax_numによる指定が必要です。

例えば

TestFormSet = formset_factory(form=TestForm, extra=3, max_num=10)
formset = TestFormSet(initial=[
                {'title': 'No1', 'date': '2019-01-01'},
                {'title': 'No2', 'date': '2019-01-02'},
])

上記のような場合、extra=3なので、initialで指定された入力済みのformが2つ、空のformが3つ、合計5つのformが 表示されます。


TestFormSet = formset_factory(form=TestForm, extra=3, max_num=10)
formset = TestFormSet()

上記の場合、単に空のformだけが3つ表示されます。


TestFormSet = formset_factory(form=TestForm, extra=3, max_num=3)
formset = TestFormSet(initial=[
                {'title': 'No1', 'date': '2019-01-01'},
                {'title': 'No2', 'date': '2019-01-02'},
])

上記の場合、initialで指定されたformが2つ、空のformは一つ表示され、合計3つのformが表示されます。max_num=3としてあるので、最大3つまでしか空のformを増やすことができないからです。


TestFormSet = formset_factory(form=TestForm, extra=3, max_num=1)
formset = TestFormSet(initial=[
                {'title': 'No1', 'date': '2019-01-01'},
                {'title': 'No2', 'date': '2019-01-02'},
])

上記の場合、max_num=1ですが、initialで指定した入力済みformが2つ表示されます。空のformは表示されません。max_numはあくまで「空のform」の数を指定するので、initialに指定した分は必ず表示されます。最上部の公式Documentのリンク中にある、shellの例も試してみてください。

また、max_numのdefault値は1000ですので、特に指定しない場合は実質、無制限となります。

また、initialを利用する際にはGETとPOSTのブロックでも同じinitialを指定しなくてはいけません。以下は公式Documentからの引用です。

If you use an initial for displaying a formset, you should pass the same initial when processing that formset’s submission so that the formset can detect which forms were changed by the user. For example, you might have something like: ArticleFormSet(request.POST, initial=[...]).

modelformset_factory

  • Modelからformsetを作る際に利用できます。save()メソッドが便利ですので、ほとんどこちらを使うことになると思います。modelformとほとんど同じ感覚で使用できます。
  • 下の例では、空のformを3つ表示し、データベースに保存するという例を示しています。
models.py
class TestModel(models.Model):
    text = models.CharField(max_length=20)
  • モデルは上のようにしました。テンプレートは最初のformset_factoryの解説で使用したものと同じです。


views.py
def make_test_modelformset(request):
    TestModelFormSet = forms.modelformset_factory(
            model=TestModel,
            fields=('text',),
            extra=3,
    )
    if request.method == 'POST':
        formset = TestModelFormSet(request.POST,queryset=TestModel.objects.none()) 
        if formset.is_valid():
            formset.save()  # データベースに保存します。
            # チェック用にデータベース上の全データを表示してみます。
            data = [x.text for x in TestModel.objects.all()]
            return HttpResponse(repr(data))
    else:
        formset = TestModelFormSet(queryset=TestModel.objects.none())
    return render(request, 'first/form1.html', {'formset': formset})
  • 先のformset_factoryとの違いは、modelformset_factoryではModelを元にformsetを作成できる点です。fields=('text',)として、formで表示したいfieldを指定しています。
  • またqueryset引数としてquerysetオブジェクトを指定でき、データベース上のデータを取得して変更することができます。上記の例では新しいデータをデータベースに追加するので、既存のデータは使いません。なので、Model.objects.none()を指定します。
  • modelformと同様に、formset.save()メソッドでデータベースに保存します。commit=falseも利用できます。
  • ここでは示しませんが、modelfromset_facotryはFormやFormSetオブジェクトを引数にとることもできます。 fieldの指定だけで良いなら上述のように書けばいいと思いますが、複雑になるならばforms.pyにmodelに対応したform(もしくはformset)を書き、viewsにimportした方が、読みやすさからもロジックの分離の観点からもいいと思います。

querysetの指定

  • querysetはdefalutではModel.objects.all()となっています。つまり、データベース上の全てのデータがformに入力された状態で表示されます。
  • querysetはPOST, GET両方で同じ表記にします。
  • querysetはqueryset objectをとるので、filterを利用することができます。
formset = TestModelFormSet(
    queryset=TestModel.objects.filter(text__startswith='a')
)
  • querysetを指定する場合、modelformset_factoryを使う際のextraに注意します。空白のformを全く表示したくない場合は0にします。

注意:querysetにスライスは利用できない

例えばデータベース上のデータに対して、10個ずつ抽出し、順番にラベルなどを振ることを想定します。

modelformset_factory関数のqueryset引数に指定するquerysetにはスライスを利用できないようです。

>>>FormSet = forms.modelformset_factory(model=TestModel, fields=('text',))
>>>formset = FormSet(queryset=TestModel.objects.all()[0:10])
>>>print(fs)
......
AssertionError: Cannot reorder a query once a slice has been taken.
  • そもそもdjangoのquerysetオブジェクトは、一度スライスをかけるとその後スライスやfilterなどの操作を禁止する仕様になっています。:公式Document
  • 例えば以下のような例です。
>>>querys = TestModel.objects.all()
>>>querys.filter(text__startswith='a')
    # ↑これは可能
>>>querys[0:2].filter(text__startswith='a')
    # ↑これはできない!!
  • このような場合、SQLでのLIMIT clauseのような指定ができないと思います。
  • この記事をご覧の方で良い方法を知っていらっしゃる方がいれば、ご教示願いたいです。
  • 良い方法なのかわからないですが、私が使っている方法を下に示しました。idが小さいデータから順番に3つ取得する例です。
    • データベースのobjectは(deleteなどで値が飛んでいても)大きさ順のidを持つので、それを利用して一度自分がスライスしたいところまでのidを取得しています。
    • 変更済みのデータには何かしらのフラグをつけます(下には示していない).例としてデータに対してラベルを追加するならば、例えばModelにlabelerというfiledを作り、これに現在ログインしているUserを入れるなどします。max_idを求める際にfileterでlabelerがNoneのものだけを取得する、などしています。
max_id = TestModel.objects.all()[2].pk  # 2番目のobjectのpk
......
formset = TestModelFormset(
    queryset=TestModel.objects.filter(id__lt=max_id)
)

inlineformset_factory

  • ForeignKeyでの親を指定して、子のmodelformsetを作成する際に使います。言い換えると、ForeignKeyをfieldsにもつModelにおいて、その親を指定してformsetを作ることができるということです。
  • 例: Django公式Documentの例を使って説明します。(若干変えてあります)
models.py
class Author(models.Model):
    name = models.CharField(max_length=20)

    def get_absolute_url(self):
        return reverse('first:author', args=[str(self.pk),])

class Book(models.Model):
    author = models.ForeignKey(Author, on_delete=models.CASCADE)
    title = models.CharField(max_length=100)
  • 上記が使用するmodelです。adminサイトから適当に複数インスタンスを作成しておきます。
  • absoluteurlは,DetailViewなどでauthorの持つbookを表示するように書いておきます。Django公式のtutorialと同じように書いたので、省略します。
views.py
def make_inline_formset(request, author_id):
    author = Author.objects.get(pk=author_id)
    BookFormSet = forms.inlineformset_factory(
            parent_model=Author,
            model=Book,
            fields=('title',),
            extra=2,
    )
    if request.method == 'POST':
        formset = BookFormSet(
            data=request.POST, 
            instance=author,
            queryset=Book.objects.none()
        )
        if formset.is_valid():
            formset.save()
            return HttpResponseRedirect(author.get_absolute_url())
    else:
        formset = BookFormSet(
                instance=author,
                queryset=Book.objects.none(),
        )
    return render(request, 'first/inline_formset.html', {'formset': formset})
  • author = Author.objects.get(pk=author_id)で取得した親オブジェクトを、inlineformset_factoryのparent_model引数に渡します。
  • またBookFormSetをインスタンス化する際に、instance=authorとしてauthorオブジェクトを渡します。
  • POST時にもGET時にも同じauthorを渡す点に注意します。

prefix

  • 複数種類のformsetを同時に使う際に使います。下のようなイメージです。

text.png

prefixの前置き

そもそもformsetを利用した場合、HTMLは以下のようになります。

>>>FormSet = forms.formset_factory(TestForm)
>>>formset = FormSet()
>>>print(formset)

<input type="hidden" name="form-TOTAL_FORMS" value="1" id="id_form-TOTAL_FORMS"><input type="hidden" name="form-INITIAL_FORMS" value="0" id="id_form-INITIAL_FORMS"><input type="hidden" name="form-MIN_NUM_FORMS" value="0" id="id_form-MIN_NUM_FORMS"><input type="hidden" name="form-MAX_NUM_FORMS" value="1000" id="id_form-MAX_NUM_FORMS">

<tr><th><label for="id_form-0-title">Title:</label></th><td><input type="text" name="form-0-title" maxlength="20" id="id_form-0-title"></td></tr>

<tr><th><label for="id_form-0-date">Date:</label></th><td><input type="text" name="form-0-date" id="id_form-0-date"></td></tr>

formsetの各formのコントロールは、form-0-titleのようなnameを持ちます。つまりFormの名前(この場合TestForm)が入ってません。ですので、複数"種類"のformからそれぞれformsetを作るには、名前が被らないように管理しないといけません。

ところで、通常のformはprefixが指定できます。The Forms API | Django documentation | Djangoに解説されています。別々のprefixを指定することで、htmlがレンダリングされる際、formのコントロールのnameを区別することができます。

ちなみにprefixを利用すれば、formsetでやっていること(同じ種類のformを複数同時に表示する)がある程度可能です。数が多い時や、modelformを利用したいならformsetを使う方が素直で簡単だと思います。

prefixを利用して異なる種類のformsetを使う

本題です。prefixとして好きな文字列を与えることで、form-0-titleのようなnameがprefix-form-0-titleのようになり、異なるformsetを区別することができます。

forms.py
class FirstForm(forms.Form):
    name = forms.CharField(max_length=10)

class SecondForm(forms.Form):
    name = forms.CharField(max_length=10)
views.py
def make_multiple_formsets(request):
    FirstSet = forms.formset_factory(form=FirstForm, extra=2)
    SecondSet = forms.formset_factory(form=SecondForm, extra=2)
    if request.method == 'POST':
        first_set = FirstSet(request.POST, prefix='first')
        second_set = SecondSet(request.POST, prefix='second')
        if first_set.is_valid() and second_set.is_valid():
            text = 'first:{}<br><br>second:{}'.format(
                    repr(first_set.cleaned_data),
                    repr(second_set.cleaned_data),
            )
            return HttpResponse(text)
    else:
        first_set = FirstSet(prefix='first')
        second_set = SecondSet(prefix='second')
    return render(
            request,
            'first/multiple_formsets.html',
            {
                'firstform': first_set,
                'secondform': second_set
            }
    )
  • 上述のように、prefix引数に適当な文字列を渡すだけです。
  • POST時もGET時も同じ引数を渡すように注意します。
  • また、formset.is_valid()でチェックする際、両方のformをチェックするようにしてください。

テンプレートへのレンダリング

  • 上述の例では{{ formset }}とだけ書いてきましたが、通常のformと同じようにfor文で回せば、細かく指定することができます。
  • 注意点として、{{ formset.management_form }}を指定する必要があります。表示はされませんが、これ自身もformです。Totalのform数などの情報が入っていて、formsetのハンドリングに必要です。
  • Formsets | Django documentation | Djangoを参照してください。特に通常のformと変わりません。
38
37
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
38
37