Django、モデルフォームセットの基本的な使い方

2018-10-17 / PythonDjangoBootstrap4

概要

Djangoでフォームセットを使うシリーズの1つです。モデルフォームセットを使い、一括でデータの作成・更新ができるようにしていきます。Githubにソースコードを置いているので、ダウンロードしたい方はクローンして利用してください。

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

保存すると、作成済みデータは編集でき、新たに作成する分のフォームが用意されます。
作成用と更新用フォームが表示される

UpdateViewCreateViewも兼ねている感じですね。もちろん、この挙動もカスタマイズできます。新規作成用フォームだけとか、更新用フォームだけにできます。Tipsもご覧ください。

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

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

モデル

次のようなモデルを例に説明していきます。ブログによくありそうな、記事を表すモデルです。

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

フォーム

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モデルのデータを複数作りたいので、Postとしています。

form引数はどのフォームで作るかです。generic.CreateView等ではfieldsexclude引数でフォームを自動生成したり、又は自分で定義したモデルフォームクラスも渡せましたが、それと同じです。特に使いたいフォームがなければfields引数かexclude引数を指定します。使いたいフォームがあれば、そのフォームクラスを指定します。

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

ビュー

クラスベースビューでも作れますが、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

モデルフォームセットを使う上での、よくあるケースや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ではなくビューの中で定義することになります。

この記事の関連記事

Djangoでフォームセットを使うシリーズ

2018-10-17 / PythonDjangoシリーズ・まとめ

- Djangoにはフォームセットという、複数のフォームを一括で扱うための機能があります。いくつか種類があり、様々な機能があるので、それらを紹介していきます。

Django、モデルフォームセット+ページング機能

2018-10-17 / PythonDjango

- Djangoでフォームセットを使うシリーズの1つです。モデルフォームセットはフォームが複数表示されますが、この表示されるフォームを複数ページに分割します。

コメント欄

記事にコメントする

Djangoビギナー

いつも開発時に参考にさせていただいております。

本記事と「Userモデルのカスタマイズ(OneToOne)」を参考に、ユーザー情報と合わせて最寄駅(2つまで)を入力できるフォームを作成しております。フォームでは「都道府県>路線>駅」の3つをドロップダウンで表示させようとしています。路線と駅は数が非常に多いので、ajaxで実装しています。

フォーム自体は作成できており、ajaxもうまく動いています。しかし、Postすると路線と駅の値が「Select a valid choice. That choice is not one of the available choices.」と表示されてしまいます。

以下がコードです。

▪️model.py

class NearStation(models.Model):
    area = models.ForeignKey(Area, on_delete=models.CASCADE, null=True)
    railway = models.ForeignKey(Railway, on_delete=models.CASCADE, null=True)
    station = models.ForeignKey(Station, on_delete=models.CASCADE, null=True)
    profile = models.ForeignKey(Profile, on_delete=models.CASCADE)

class Area(models.Model):
    name = models.CharField(max_length=255)

    def __str__(self):
        return self.name


class Railway(models.Model):
    name       = models.CharField(max_length=255)

    def __str__(self):
        return self.name

class Station(models.Model):
    name       = models.CharField(max_length=255)
    railway    = models.ForeignKey(Railway, on_delete=models.PROTECT)
    area       = models.ForeignKey(Prefecture, on_delete=models.PROTECT)

    def __str__(self):
        return self.name

▪️form.py

class NearStationForm(forms.ModelForm):
    area = forms.ModelChoiceField(queryset=Prefecture.objects.all(), label='Area', required=True)
    railway = forms.ModelChoiceField(queryset=Railway.objects.none(), label='railway', required=True)
    station = forms.ModelChoiceField(queryset=Station.objects.none(), label='station', required=True)

    class Meta:
        model = NearStation
        fields = ('area', 'railway', 'station',)

NearStationFormSet = forms.modelformset_factory(
    model=NearStation,
    form=NearStationForm,
    extra=2
)

▪️view.py

class UserCreate(generic.CreateView):

    def get(self, request):
        """GETでのリクエストで呼ばれる"""
        user_form = UserCreateForm()
        profile_form = ProfileForm()
        near_station_form = ProfileNearStationFormSet()
        context = {
            'user_form': user_form,
            'profile_form': profile_form,
            'near_station_form': near_station_form,
        }
        return render(request, 'accounts/user_create.html', context)

    def post(self, request):
        user_form = UserCreateForm(request.POST)
        profile_form = ProfileForm(request.POST)
        near_station_form = ProfileNearStationFormSet(request.POST)

        if user_form.is_valid() and profile_form.is_valid() and near_station_form.is_valid():

            user = user_form.save(commit=False)
            user.is_active = True
            user.save()

            profile = profile_form.save(commit=False)
            profile.user = user
            profile.save()

            station = near_station_form.save(commit=False)
            station.profile = profile
            station.save()

            return redirect('accounts:user_create_done')

        context = {
            'user_form': user_form,
            'profile_form': profile_form,
            'near_station_form': near_station_form,
        }
        return render(request, 'accounts/user_create.html', context)

form.py を下記に変更すると2つ目のフォームセットの値は正常に動きますが、1つ目のフォームセットはエラーとなります。。

class NearStationForm(forms.ModelForm):
    area = forms.ModelChoiceField(queryset=Prefecture.objects.all(), label='Area', required=True)
    railway = forms.ModelChoiceField(queryset=Railway.objects.none(), label='railway', required=True)
    station = forms.ModelChoiceField(queryset=Station.objects.none(), label='station', required=True)

    def __init__(self, *args, **kwargs):
        super(NearStationForm, self).__init__(*args, **kwargs)
        for i in range(2):
            area = 'form-'+str(i)+'-near_station_area'
            railway = 'form-'+str(i)+'-near_station_railway'

            if area in self.data:
                self.fields['railway'].queryset =Railway.objects.filter(station__area_id__exact=self.data[area]).distinct()

            if area in self.data and railway in self.data:
                self.fields['station'].queryset =Station.objects.filter(area_id__exact=self.data[area], railway_id__exact=self.data[railway])

    class Meta:
        model = NearStation
        fields = ('area', 'railway', 'station',)

NearStationFormSet = forms.modelformset_factory(
    model=NearStation,
    form=NearStationForm,
    extra=2
)

もし解決案がおわかりであれば、ご教示いただけると嬉しいです。なにと何卒よろしくお願いいたします。

コメントに返信する

なりと
class NearStationForm(forms.ModelForm):
    area = forms.ModelChoiceField(queryset=Prefecture.objects.all(), label='Area', required=True)  # =Prefecture.objectsだけでも同じ動作
    railway = forms.ModelChoiceField(queryset=Railway.objects.all(), label='railway', required=True)
    station = forms.ModelChoiceField(queryset=Station.objects.all(), label='station', required=True)

queryset引数は選択肢となってHTML上に表示されますが、それとは別に受け付ける値のリストのような意味も持ちます。all()とすればすべての選択肢が表示され、すべての選択肢を受け入れます。none()としたならば選択肢も表示されませんし、仮に選択肢をJavaScriptで作成しても、それらの値は受け入れてくれません。

filter()などを使って選択肢を絞り込むこともできますが、これもfilter()で絞り込んだ選択肢しか受け入れないので注意です。

all()としておけば間違いありませんが、表示される選択肢自体に手を加えたい場合もあると思いますので、その場合はJavaScriptで選択肢を操作してください。__init__内でqueryset引数を操作することでも実装はできるのですが、今回のようにフォームセットが絡んでいたりすると複雑になりがちです。

Djangoビギナー

非常に参考になりました。all()にして、JavaScriptで対応することにいたします。誠にありがとうございました。

名無し

いつも拝見しております。

月間の「予測値」と「実績」のような値を日別に入力するようなフォームを考えているのですが、 その場合は、モデルフォームを使用するのが常套手段でしょうか。 この場合、最初に入力しようとしたときには、何もデータがない状態なので、 一度1日から月末まで0データを作成し、それを修正する画面でモデルフォームセットを使うことを想定しております。

イメージとしては、下のようなテーブル上で入力するスタイルです。

日付 予測値 実績 1   100  100 2   130  120 3   200  250 以下月末まで…

同じ日付のデータは作成されないように、日付にuniqueを指定しようと思っています。 モデルフォームセットを使う場合は、extra=0として、 余計なデータを追加させないように考えております。

よろしくお願いいたします。

コメントに返信する

なりと

モデルフォームセットで問題ないと思われます。月間という概念自体をモデルとして表現し、日のデータをインラインフォームセットを使って表示することもできます。この方法は月間データ自体にコメントやら作成者やら作成日やら、そういった情報を持たせることもでき、扱いやすいと思います。

サンプルプロジェクトを作ってみたので、参考にしてみてください。