Django、モデルフォームセットを使う
概要
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を使い、横に並べてみました。
保存すると、作成済みデータは編集でき、新たに作成する分のフォームが用意されます。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_p
やas_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ではなくビューの中で定義することになります。