Django、モデルフォームセットを使う

Twitterでシェア FaceBookでシェア はてなブックマークでシェア

Python - Django
2018年11月26日0:01に更新(約18日前)
2018年10月17日6:16に作成(約57日前)

旧ブログ移行記事です。

概要

Djangoにはフォームセットという、複数のフォームを作成・管理するための機能があります。 1度に複数のデータを追加したい場合...同じモデルフォームを何個もテンプレートへ渡したいならば、モデルフォームセットの出番です。

今回使うモデル

from django.db import models
from django.utils import timezone


class Post(models.Model):
    title = models.CharField('タイトル', max_length=200)
    text = models.TextField('本文')
    date = models.DateTimeField('日付', default=timezone.now)

    def __str__(self):
        return self.title

見た目

3つの登録用フォームが表示されています。 Bootstrap4を使い、横に並べてみました。 3つの登録用フォームが並ぶ

保存すると、作成済みデータは編集でき、新たに作成する分のフォームが用意されます。UpdateViewとCreateViewも兼ねている感じですね。もちろん、この挙動もカスタマイズできます。 作成用と更新用フォームが表示される

空欄チェックなどのバリデーションもちゃんと動きます。 バリデーションチェックが動作している

ちなみにですが、何も入力がないフォームは無視されます。空欄のまま登録されるようなことはないので、安心です。

基本的な使い方

forms.py

PostCreateFormは、よくあるモデルフォームです。 PostCreateFormSetが、モデルフォームセットになります。

from django import forms
from .models import Post


class PostCreateForm(forms.ModelForm):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        for field in self.fields.values():
            field.widget.attrs['class'] = 'form-control'

    class Meta:
        model = Post
        fields = '__all__'


# これがモデルフォームセット
PostCreateFormSet = forms.modelformset_factory(
    Post, form=PostCreateForm, extra=3
)

forms.modelformset_factory関数を呼び出すことで作成できます。 第一引数はどのモデルで作るかです。記事を複数作りたければPostですし、一度に何日か分の日記を作るならばDiaryとかになります。 form引数は、どのフォームで作るかです。CreateView等では自動的にモデルフォームが生成されましたが、自分で定義したモデルフォームも渡せました。それと同じです。特に使いたいフォームがなければ、fields引数かexclude引数を指定します。

extra引数は、新規登録用のフォームを何個表示するかです。

PostCreateFormSet = forms.modelformset_factory(
    Post, form=PostCreateForm, extra=3
)

views.py

クラスベースビューでも作れますが、CreateViewそのままでは使えなかったり、フォームセットは特に複雑になりがちなので、慣れないうちは関数ビューで作るほうがおすすめです。

from django.shortcuts import render, redirect
from .forms import PostCreateFormSet


def add(request):
    formset = PostCreateFormSet(request.POST or None)
    if request.method == 'POST' and formset.is_valid():
        formset.save()
        return redirect('app:index')

    context = {
        'formset': formset
    }

    return render(request, 'app/post_formset.html', context)

base.html

bootstrap4のスターターテンプレートそのままです。

<!doctype html>
<html lang="ja">
  <head>
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

    <!-- Bootstrap CSS -->
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">

    <title>フォームセット</title>
  </head>
  <body>
    <div class="container mt-3">
        {% block content %}{% endblock %}
    </div>

    <!-- Optional JavaScript -->
    <!-- jQuery first, then Popper.js, then Bootstrap JS -->
    <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js" integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49" crossorigin="anonymous"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js" integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy" crossorigin="anonymous"></script>
  </body>
</html>

post_formset.html

{% extends 'app/base.html' %}

{% block content %}
<form action="" method="post">
    <div class="row">
        {% for form in formset %}
            <div class="col-sm-4">
                {{ form.as_p }}
            </div>
        {% endfor %}
    </div>
    {{ formset.management_form }}
    {% csrf_token %}
    <button type="submit" class="btn btn-primary">送信</button>
</form>
{% endblock %}

フォームセットはフォームの集まりです。forで、フォームを1つずつ取り出すことができます。取り出したフォームは、普段使っているフォームと同様に使えます。今回はas_pで表示しました。

        {% for form in formset %}
            <div class="col-sm-4">
                {{ form.as_p }}
            </div>
        {% endfor %}

formset自体も、as_pas_ulで簡単に出力できます。見た目に拘らないときは、これが楽です。

{{ formset.as_p }}

{{ formset.management_form }}は、formsetを使う際のおまじないです。フォームセット内には複数のフォームがあり、それらを管理するためのinput type="hidden"なデータが幾つか必要なのです。それらを作成するのが、{{ formset.management_form }}です。必ずつけましょう。

{{ formset.management_form }}

Tips

5件しか登録できないようにしたい場合

あらかじめ、データを追加できる上限を設定したいときはmax_num引数を指定します。

PostCreateFormSet = forms.modelformset_factory(
    Post, form=PostCreateForm, extra=3, max_num=5
)

3つすでに追加していたので、3件の編集用フォームと2件の新規作成用フォームが表示されました。編集用のフォームをすべて表示し、残りの枠があればextraの件数だけ新規フォームを表示しようとします。もちろんmax_numを超えない範囲で。

フォームフィールドをforで取り出したい

通常のフォームとほぼ同様に使えるのですが、少し落とし穴があります。

<form action="" method="post">
    <div class="row">
        {% for form in formset %}
            <div class="col-sm-4">
            {{ form.non_field_errors }}
            {% for field in form.visible_fields %}
                <div class="form-group">
                    <label for="{{ field.id_for_label }}">{{ field.label_tag }}</label>
                    {{ field }}
                    {{ field.errors }}
                </div>
            {% endfor %}
            {% for field in form.hidden_fields %}
                {{ field }}
            {% endfor %}
            </div>
        {% endfor %}
    </div>
    {{ formset.management_form }}
    {% csrf_token %}
    <button type="submit" class="btn btn-primary">送信</button>
</form>

通常のフォームならば、よく以下のように書きます。

{% for field in form%}

しかし、この書き方だと以下のようにidという空欄の部分が表示されます。

フォームセットの各フォームには、idフィールドが自動的に付与されます。これはinput type="hidden"な要素として出力されています。これを表示させたくないので、input type="hidden" なものを含めない{% for field in form.visible_fields %} として取り出し、更に{% for field in form.hidden_fields %} としてhiddenなフィールドは別に出力しました。

{% for field in form.hidden_fields %} の代わりに{{ form.id }}と出力しても動作はしますが、{% for field in form.hidden_fields %} のほうが汎用的な書き方です。他にhiddenなフィールドがあった場合でも問題なく動作します。

フォームフィールドを手作業で取り出す

手作業で取り出す場合も、id等のhiddenなフィールドの取り出しを忘れなければ通常のフォームと同様に扱えます。

<form action="" method="post">
    <div class="row">
        {% for form in formset %}
            <div class="col-sm-4">
                {{ form.non_field_errors }}

                <div class="form-group">
                     <label for="{{ form.title.id_for_label }}">{{ form.title.label_tag }}</label>
                    {{ form.title }}
                    {{ form.title.errors }}
                </div>

                <div class="form-group">
                     <label for="{{ form.text.id_for_label }}">{{ form.text.label_tag }}</label>
                    {{ form.text }}
                    {{ form.text.errors }}
                </div>

                <div class="form-group">
                     <label for="{{ form.date.id_for_label }}">{{ form.date.label_tag }}</label>
                    {{ form.date }}
                    {{ form.date.errors }}
                </div>

            {% for field in form.hidden_fields %}
                {{ field }}
            {% endfor %}

            </div>
        {% endfor %}
    </div>
    {{ formset.management_form }}
    {% csrf_token %}
    <button type="submit" class="btn btn-primary">送信</button>
</form>

削除チェックボックスをつける

can_delete引数にTrueと指定すると、削除用のチェックボックスをつけれます。

PostCreateFormSet = forms.modelformset_factory(
    Post, form=PostCreateForm, extra=3, max_num=5, can_delete=True
)

見た目はこんな感じです。

上の「フォームフィールドをforで取り出したい」を行っている場合はテンプレートはそのままで問題ありません。 もし手作業で取り出している場合は、{{ form.DELETE }} で削除用チェックボックスを取り出せます。更に編集用フォームにだけ削除チェックボックスをつけたいならば、{% if form.instance.pk %} で判断すればOKです。

                {% if form.instance.pk %}
                    <div class="form-group">
                        <label for="{{ form.DELETE.id_for_label }}">{{ form.DELETE.label_tag }}</label>
                        {{ form.DELETE }}
                    </div>
                {% endif %}

初期値を与える

新規作成用フォームに初期値を与えたい場合は、フォームセットのインスタンス化時にinitial引数を渡しますが、通常のフォームとはちょっと違います。リストで渡します。

    initial = [
        {'title': '映画を見た', 'text': '楽しかった'},
        {'title': 'お寿司を食べた', 'text': 'おいしかった'},
    ]
    formset = PostCreateFormSet(request.POST or None, initial=initial)

{'title': '映画を見た', 'text': '楽しかった'} は、1番目フォームのタイトルを「映画を見た」、本文を「楽しかった」にします。 {'title': 'お寿司を食べた', 'text': 'おいしかった'} は、2番目フォームのタイトルを「お寿司を食べた」、本文を「おいしかった」にします。

一点注意なのは、このまま送信ボタンを押すと保存されません。初期値があった場合は初期値のままだと保存されず、何か変更がないと保存されません。

新規作成用フォームだけ表示する

更新用フォームは別の場所に表示するとかで、新規作成用フォームだけ表示したいという場合があります。 これは簡単で、queryset引数を以下のようにします。

formset = PostCreateFormSet(request.POST or None, queryset=Post.objects.none())

編集用フォームを絞り込む

queryset引数は編集用のフォーム部分に関連する引数です。 noneでは表示がされなくなり、filterで絞り込むことができます。

formset = PostCreateFormSet(request.POST or None, queryset=Post.objects.filter(title='記事1'))

新規作成用フォームを表示したくない場合は、ファクトリ関数のextraを0にすればOKです。

PostCreateFormSet = forms.modelformset_factory(
    Post, form=PostCreateForm, extra=0,
)

extra引数やmax_num引数が動的に変わる場合は、PostCreateFormSetをforms.pyではなくビューの中で定義することになります。

Twitterでシェア FaceBookでシェア はてなブックマークでシェア

記事にコメントする