投稿日:2020年10月10日
ブログの様なWebサイトでは、同時に複数のレコードを編集したい場面がよくあります。DjangoのModelFormSetはそのような機能を簡単に作ることのできる機能です。この記事ではModelFormSetの使い方の説明とその解説をしています。
DjangoのModelFormは便利なのですが、そのままでは同時に複数のModelを作成・編集することはできません。Djangoはそれを解決するModelFormSetというものを用意しています。
この記事ではModelFormSetの使い方について詳しく解説しています。
この記事は以下の環境で試されました。
Djangoのアプリケーションで以下のようなModelとFormがあるとします。
from django.db import models
class Article(models.Model):
"""記事を管理するModel.
Args:
title(str) : 記事のタイトル。ユーザーに表示される。
created(date) : 記事の作成日時。自動管理。
edited(date) : 記事の最終更新日時。自動管理。
body(str) : 記事の本文。
Returns:
[type]: [description]
"""
title = models.CharField("title", max_length=50,null=False,blank=False)
created = models.DateField("created", auto_now_add=True)
edited = models.DateField("edited", auto_now=True)
body = models.TextField("body",null=False, blank=False)
class Meta:
verbose_name = "Article"
verbose_name_plural = "Articles"
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse("Article_detail", kwargs={"pk": self.pk})
from .models import Article
from django import forms
class ArticleForm(forms.ModelForm):
"""記事モデルを作成・編集するform
"""
class Meta:
model = Article
fields = ("title","body")
ModelFormSetはdjango.forms.modelformset_factory()という関数を使って作成します。ただしこの関数はModelFormSetのインスタンスを返すわけではなく、ModelFormSetのクラスを返すファクトリーメソッドであることに注意してください。
実際に使ってみましょう。
from django.shortcuts import render, redirect
from django.urls import reverse
from django.forms import formset_factory, modelformset_factory
from .forms import ArticleForm
from .models import Article
def formset_article(request):
"""
Getリクエストに対しては単純なformsetを作成する
Postリクエストに対してはformを編集する
"""
ArticleFormSet = modelformset_factory(Article, form=ArticleForm, extra=3)
formset = ArticleFormSet(request.POST or None, queryset=Article.objects.none())
if request.method == "POST" and formset.is_valid():
formset.save()
return redirect("blog:list_article")
else:
context = {
"formset": formset
}
return render(request, template_name="article_formset.html", context=context)
def list_article(request):
"""Article一覧のリスト
"""
article_list = Article.objects.all()
context = {
"article_list": article_list,
"formset_article_url": reverse("blog:formset_article")
}
return render(request, context=context, template_name="article_list.html")
注目するのはformset_article関数です。modelformset_factory()関数の引数はそれぞれ以下の様になっています。
この関数で作成したArticleFormSet()でformsetを作成しています。このクラスに与えているquerysetと言う引数に注目してみてください。この引数に渡したQuerySetに合わせて、通常の空欄のformに加えてそのQuerySetに含まれるレコードの変更formをユーザーに表示できます。今回はわかりやすさのため、とりあえず変更formは表示しない様にArticle.object.none()、つまり空のQuerySetを渡しています。
生成したModelFormSetに対応するtemplateは以下の様になります。
<!DOCTYPE html>
<html>
<head> </head>
<body>
<h2>記事フォーム</h2>
<form action="" method="POST">
{% csrf_token %} {{formset}}
<button>追加</button>
</form>
</body>
</html>
すると以下のようなフォームが作成されます。
既存のレコードの編集フォームはmodelformset_factory()で生成したFormSetクラスに渡すquerysetに編集したいQuerySetを渡すことで作成できます。
例えば先程のformsetに全てのArticleレコードの編集フォームも追加するコードは以下のようになります。
[...]
def formset_article(request):
"""
Getリクエストに対しては単純なformsetを作成する
Postリクエストに対してはformを編集する
"""
ArticleFormSet = modelformset_factory(
Article,
form=ArticleForm,
extra=3)
formset = ArticleFormSet(
request.POST or None,
queryset=Article.objects.all()) # 全てのArticleも編集
if request.method == "POST" and formset.is_valid():
formset.save()
return redirect("blog:list_article")
else:
context = {
"formset": formset
}
return render(request, template_name="article_formset.html", context=context)
[...]
DjangoTemplateに{{ formset }}を渡した際に生成されるhtmlは少し特徴があります。
通常のFormやModelFormの場合、生成されるformのinputはname属性としてfield名が指定されます。
しかし、その指定方法だと複数のFormが生成された際、同じname属性のinputが複数できてしまうため、Postデータを受け取るサーバー側はどのデータがどのinputの値であるかわからなくなってしまいます。
その解決策としてModelFormSetで生成されるinputはname属性がform-{number}-{field}という値になっています。numberは何番目のinput要素であるかという値です。
FormSetは通常のformに加えてManagementFormも追加で作成されます。ManagementFormにはformsetの内容がhidden要素で格納されています。
このManagementFormはそのまま{{ formset }}で渡した場合には自動的に追加されます。
<input type="hidden" name="form-TOTAL_FORMS" value="3" 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">
何らかの理由でformをカスタマイズしたい場合などにはManagementFormを生成する必要があります。ManagementFormはformsetのmanagement_form()で生成できます。
{{ formset }}タグを手動で書き換える場合には以下の様になります。
<!DOCTYPE html>
<html>
<head> </head>
<body>
<h2>記事フォーム</h2>
<form action="" method="POST">
{% csrf_token %}
{{formset.management_form}}
{% for form in formset %}
{% for field in form %}
{{field}}
{% endfor %}
{% endfor %}
<button>追加</button>
</form>
</body>
</html>
ブログなどのフォームをフロント側でjavascriptで追加、削除などをする場合などにはManagementFormなどの関係で少し工夫が必要になります。
今回は簡単なサンプルの例として、ModelFormをフロント側のjavascriptで受け取り、受け取ったフォームのHTMLから3つのフォームを生成する場合のコードを載せておきます。
<!DOCTYPE html>
<html>
<body>
<h2>記事フォーム</h2>
<form action="" method="POST">
{% csrf_token %}
<div id="box-form"></div>
<input type="submit"></input>
</form>
<script>
var formBox = document.querySelector("#box-form");
var formListStr = "";
for (var i = 0; i < 3; i++) {
formListStr += `{{ form }}`;
}
formBox.innerHTML = formListStr;
</script>
</body>
</html>
from django.shortcuts import render, redirect
from django.urls import reverse
from django.forms import modelformset_factory
from .forms import ArticleForm
from .models import Article
def form_article_mutable(request):
"""
Args:
request ([type]): [description]
"""
if request.method == "GET":
form = ArticleForm()
context = {
"form": form
}
return render(request, context=context, template_name="article_form.html")
elif request.method == "POST":
post_data = request.POST
# データを取得する
post_data_title_list = post_data.getlist("title")
post_data_body_list = post_data.getlist("body")
data_len = len(post_data_body_list)
# マネジメントフォームのデータを作成
insert_data = {
'form-TOTAL_FORMS': str(data_len),
'form-INITIAL_FORMS': '0',
'form-MAX_NUM_FORMS': '',
}
# フォームのデータを取得する
for i, post_data_title in enumerate(post_data_title_list):
insert_data[f"form-{i}-title"] = post_data_title
insert_data[f"form-{i}-body"] = post_data_body_list[i]
ArticleFormSet = modelformset_factory(Article, form=ArticleForm, extra=1)
formset = ArticleFormSet(insert_data)
if formset.is_valid():
formset.save()
return redirect("blog:list_article")
[...]
上のコードはPOSTでデータを受け取った際にはFormSetでまとめてデータをバリデーションして保存します。フロント側ではjavascriptで複製したフォームのipnut要素のname属性は同じ値になります。
この時、サーバー側でそれらの値を受け取るにはrequest.POST.get_list()関数を使います。
FormSetに渡すデータはマネジメントフォームが必要になるためそれの作成もしています。また、POSTされたデータについてもキーに順番を表す値を入れています。
(正直、ここまでするのであればFormをforループで回してそれをtransactionで囲んだほうが良い気もする…)