naritoブログ

このブログはPython/DjangoとBootstrap4で作成されました
Githubのソースコード

Django、モデルフォームセットを汎用ビューから使う

約226日前 2018年1月2日19:22
プログラミング関連
Django Python

概要


Djangoでは、CreateViewを使うことでデータの追加機能が作れます。

例えば以下のようなモデルがあったとして...
from django.db import models
from django.utils import timezone


class Day(models.Model):
    """日記の一日となるモデル"""
    title = models.CharField('タイトル', max_length=200)
    text = models.TextField('本文')
    date = models.DateTimeField('日付', default=timezone.now)

    def __str__(self):
        return self.title



データを追加するためのビューはシンプルなものだと以下のようになります。
class AddView(generic.CreateView):
    model = Day
    form_class = DayCreateForm
    success_url = reverse_lazy('diary:index')



テンプレートは、例えばシンプルに書くなら以下のように。
{% extends 'diary/base.html' %}

{% block content %}
<form action="" method="POST">
  <table class="table">
      <tr>
        <th>タイトル</th>
        <td>{{ form.title }}</td>
      </tr>
      <tr>
        <th>本文</th>
        <td>{{ form.text }}</td>
      </tr>
      <tr>
        <th>日付</th>
        <td>{{ form.date }}</td>
      </tr>
  </table>
  <button type="submit" class="btn btn-primary">送信</button>
  {% csrf_token %}
</form>
{% endblock %}




これは1件のデータが追加できるのですが、もし、一度に複数のデータを追加したい場合はどうでしょうか。
そのような場合に、モデルフォームセットが使えます。

forms.py


forms.pyに追加します。
from django import forms
from .models import Day


class DayCreateForm(forms.ModelForm):

    class Meta:
        model = Day
        fields = '__all__'


# ここが追加!!
DayCreateFormSet = forms.modelformset_factory(
    Day, form=DayCreateForm, extra=3
)




追加されたのは以下です。第一引数はどのモデルか、form引数は基になるフォームがあれば指定、extraは1度に何件追加できるようにするかです。
DayCreateFormSet = forms.modelformset_factory(
    Day, form=DayCreateForm, extra=3
)



特に基となるフォームがないならば、fieldsかexcludeを指定してもOKです。
DayCreateFormSet = forms.modelformset_factory(
    Day, fields='__all__', extra=3
)



views.py


では、views.pyです。残念ながらCreateViewそのままでは対応ができません。
FormViewを使い、メソッドを上書きしてみます。FormViewなのでtemplate_nameも指定するよう、忘れないようにしてください。
class AddView(generic.FormView):
    template_name = 'diary/day_formset.html'
    form_class = DayCreateFormSet
    success_url = reverse_lazy('diary:index')

    def form_valid(self, form):
        form.save()  # ここで保存される
        return super().form_valid(form)  # これは、ただのリダイレクト処理です。redirect関数等でも良いです。



day_formset.html


day_formset.htmlは以下のようになります。
{% extends 'diary/base.html' %}

{% block content %}
<form action="" method="POST">
{{ form.management_form }} 
{% for fm in form %}
{{ fm.id }}
  <table class="table">
      <tr>
        <th>タイトル</th>
        <td>{{ fm.title }}</td>
      </tr>
      <tr>
        <th>本文</th>
        <td>{{ fm.text }}</td>
      </tr>
      <tr>
        <th>日付</th>
        <td>{{ fm.date }}</td>
      </tr>
  </table>
  {% endfor %}

  <button type="submit" class="btn btn-primary">送信</button>
  {% csrf_token %}
</form>
{% endblock %}



{{ management_form }}は、formsetを使う際のおまじないです。フォームセット内には複数のフォームオブジェクトがあり、それらを管理するためのinput type="hidden"なデータが幾つか必要なのです。それらを作成するのが、management_formです。つけておけば間違いありませんから。
{{ form.management_form }} 


今まではformという名前でテンプレートに1フォーム渡されていましたが、今回はformという名前に複数のフォームオブジェクトが紐付いています。それを一つづつ取り出すため、forで回します。
{% for fm in form %}



これもおまじないと思った方が良いかもしれません。実を言うと、フォームセットでは過去に追加したデータも表示されます。ある意味UpdateViewも兼ねていると言えるでしょう。過去に追加したデータを書き換える際に、このidが必要になるのです。fm.pkでは動かないので注意しましょう。
{{ fm.id }}


{{ fm.title }}などは触れなくてもわかるかと思います。この部分は今まで{{ form.title }}のように書けた場所です。{% for field... のようなフォームフィールドの取り出し方も良いのですが、それをする場合は以下のように書きます。visibleは目に見えるといった意味で、上で書いたform.idなんかをこれで無視できます。これは通常のフォームでも使えるので、覚えておくと良いでしょう。
{% for field in fm.visible_fields %}{{ fields }}{% endfor %}


hiddenなフィールドだけを表示することもできますし、field.is_hiddenでのhiddenなフィールドかの確認もできます。
上での{{ fm.id }}よりも、こちらを使って全て出力させておいたほうが確実ではあります。
{% for field in fm.hidden_fields %}{{ fields }}{% endfor %}



変数名を整える


さて、{{ fm.title }}のような書き方ですが、正直名前がわかりづらいはずです。私は横着なのでこれいっかなーと思いますが、しっかりした方はformsetとformという名前を適切に使いたいはずです。
その場合は、まずviews.pyにて以下のように書きます。djangoの汎用ビューに詳しい人ならば、コメントで内容がわかると思います。formという名前ではなく、formsetという名前でテンプレートに渡すだけですね。
    def get_context_data(self, **kwargs):
        if 'form' in kwargs:
            # form_invalidでのget_context_data呼び出しはこっち
            kwargs['formset'] = kwargs['form']
        else:
            # getからのget_context_data呼び出しはこっち
            kwargs['formset'] = self.get_form()
        return super().get_context_data(**kwargs)




day_formset.htmlは、以下になります。formがformsetに、fmがformという名前に変わっただけです。
こちらは名前的にもちゃんとできてるし、しっくりきますね。
{% extends 'diary/base.html' %}

{% block content %}
<form action="" method="POST">
{{ formset.management_form }} 
{% for form in formset %}
{{ form.id }}
  <table class="table">
      <tr>
        <th>タイトル</th>
        <td>{{ form.title }}</td>
      </tr>
      <tr>
        <th>本文</th>
        <td>{{ form.text }}</td>
      </tr>
      <tr>
        <th>日付</th>
        <td>{{ form.date }}</td>
      </tr>
  </table>
  {% endfor %}

  <button type="submit" class="btn btn-primary">送信</button>
  {% csrf_token %}
</form>
{% endblock %}



デモ


いくつかデータを追加してみましょう。
追加画面に、追加用のフォームが3つあります。extra=3としたからです。


追加すると、ちゃんと同時に追加されます。


もう一度追加画面に行くと、既に追加したデータも表示されていますね。UpdateViewも兼ねていると書きましたが、このように表示されます。


もちろん、追加用の空のフォームも表示されています。extraの数だけ表示されます。



あらかじめ、データを追加できる上限を設定したいときはmax_num引数に指定します。
max_numに5と指定すると、今回ならば既存の3件と追加用の2件が表示されます。
既に5件以上あるときは、追加用のフォームが表示されません。
DayCreateFormSet = forms.modelformset_factory(Day, form=DayCreateForm, extra=3, max_num=5)



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


モデルフォームセットにはまだまだ機能があります。can_delete引数にTrueと指定し...
DayCreateFormSet = forms.modelformset_factory(Day, form=DayCreateForm, extra=3, can_delete=True)



テンプレートに {% if form.instance.pk %}を足します。これは、そのフォームが追加用の空フォームではない場合に表示されます。
      <tr>
        <th>日付</th>
        <td>{{ form.date }}</td>
      </tr>
      {% if form.instance.pk %}
      <tr>
        <th>削除</th>
        <td>{{ form.DELETE }}</td>
      </tr>
      {% endif %}


すると、削除用のチェックボックスができましたね。


ちなみにですが、{% for field in form.visible_fields %}という書き方で取り出した場合は、削除用チェックボックスも自動で表示されます。
名無し 約16日前 2018年7月31日16:41 返信する
添付ファイルダウンロード(urls.zip)
いつも参考にさせて頂いています。
現在一括登録、編集できるwebアプリを作ろうしてまして、formsetの検証をしています。
複数登録、編集するフォームの表示まではできたのですが、実際の反映処理ができません。
よろしければご教授頂ければと思います。
以下ファイルを添付いたします。
なりと 約15日前 2018年8月1日14:12
forms.pyのBookCreateFormにfields = ('bookType'...とbookTypeフィールドを含めているのですが、book_multiAdd.htmlにはbookType用の入力欄がないためです。
fieldsからbookTypeを消すか、テンプレートで<td>{{ fm.bookType }}</td>のように入力欄を作成してください。

book_multiAdd.htmlに、
{{ form.management_form }}
{% for fm in form %}
{{ fm.id }}
{{ fm.errors }}
のように{{ fm.errors }}とつけるとフォームのエラー内容が一括表示されるので、何か動かないなーという際につけるとエラーを発見できたりします。
名無し 約15日前 2018年8月1日15:42
なりとさん、ご返信ありがとうございます。
ご指摘の通り、テンプレートを変更したらいけました!
エラーの表示についてもありがとうございます!

追加の質問で申し訳ありませんが、
登録画面表示時にモデルに登録されているデータが全て表示されるのですが、
これを新規登録時は表示させない、または特定条件を満たすデータのみ表示させることは可能でしょうか?
なりと 約15日前 2018年8月1日15:50
フォームセットクラスの引数に、queryset=Book.objects.none() を追加してみてください。
ある値以上などにしたい場合は、Book.objects.filter...とします。
よく使うので、記事にもいずれ反映させると思います。
名無し 約15日前 2018年8月1日16:39
早速のご返信ありがとうございます。
forms.pyで以下のように変更してみましたが、エラーとなってしまいました。
BookFormSet = forms.modelformset_factory(
Book, form=BookCreateForm, extra=1,can_delete=True,queryset=Book.objects.none())

エラー:
TypeError: modelformset_factory() got an unexpected keyword argument 'queryset'

もしご存知でしたらよろしくお願いいたします。
なりと 約15日前 2018年8月1日17:15
失礼しました、queryset引数はフォームセットのインスタンス化時に渡す必要があります。

multiAddViewに、以下のメソッドを追加してみてください。get_formメソッドは、フォームを取得するのに使われるメソッドです(これをつけると、クラス属性form_classは必要なくなります)。
def get_form(self, form_class=None):
 return BookFormSet(self.request.POST or None, queryset=Book.objects.none())
名無し 約15日前 2018年8月1日19:54
ご返信のメソッドを追加後、想定していた挙動になりました。
どうもありがとうございます!

何度もすみません。。
formsetで登録する際、内部的に値をセットしたいのですが中々うまくいきません。
def form_valid(self, form):
book.save(commit=False)
book.editer = str(self.request.user) #セットしたい値
book.save()
return super().form_valid(form)
以下のエラーで苦慮しています。
'list' object has no attribute 'save'
なりと 約15日前 2018年8月1日20:19
フォームセットはフォームの集まりで、[form1, form2. form3]のようになっています。
このようなリストに対してsaveメソッドを呼び出したので、そのようなエラーが出ました。
for文で回すとフォーム1つ1つにアクセスすることができます。

for fm in form:
 book = fm.save(commit=False)
 book.editer = str(self.request.user)
 book.save()
名無し 約15日前 2018年8月1日21:39
なるほどそういうことだったんですね。大変勉強になりました。
何度もご回答頂きましてありがとうございました。
今後もブログ等楽しみにしています。
名無し 約14日前 2018年8月2日16:30
添付ファイルダウンロード(code.txt)
しつこくすみません。。
現在formsetによる一括編集、削除処理を試みていますが、
削除処理がうまく実行されずエラー表示されないので検討もつかない状況です。
お忙しい中すみませんがご存知でしたらご教授ください。
なりと 約14日前 2018年8月2日18:43
form_validメソッドを以下のようにしてください。
https://paiza.io/projects/gySs_OYZshaMr0m7KxXEgA

今までと少しやり方を変えました。
formsetを使う際にsave(commit=False)とする場合、削除チェック処理も自分でやらなくてはなりません。その削除処理部分がfor book in form.deleted_objects:です。

ちょっと気になったのですが、forms.pyのextra引数を1や2などにして空のフォームを作ると、空のフォームを埋めないと「nameが空欄です」と表示されていませんか?
モデルフォームセットでは、空欄のフォームがあっても本来は無視されます。
forms.pyのBookCreateForm、fieldsの'impressionCount'を消すと治ると思います。('impressionCount'が空欄です、ではなくnameが空欄ですと表示されるのはどこか変だとは思いますが、とりあえず気にしなくてよいと思います。)
名無し 約13日前 2018年8月3日11:22
ご教授頂いた方法で削除処理できました。ありがとうございます。
また、instancesが新たに作成、更新されたbookが入ったリストになるんですね。

>ちょっと気になったのですが、forms.pyのextra引数を1や2などにして空のフォームを作ると、>空のフォームを埋めないと「nameが空欄です」と表示されていませんか?
>モデルフォームセットでは、空欄のフォームがあっても本来は無視されます。

ご指摘の通り、nameが空欄ですと表示され、消した後処理できるようになりました。
現状フォーム上で何も入力しない空欄レコードがある場合に登録すると、book.editer = str(self.request.user)でDB上はレコードが登録されています。
この場合、フォームの各フィールドが空欄でない場合のみ、book.editer = str(self.request.user)をする処理にしなければいけないでしょうか?
なりと 約13日前 2018年8月3日12:58
>現状フォーム上で何も入力しない空欄レコードがある場合に登録すると、book.editer = str(self.request.user)でDB上はレコードが登録されています。

空欄のフォームがあった場合に、DBに空欄のデータ(editerだけ入力済み)が登録されているということでしょうか。
もしそうでしたら、私の環境ではちょっと再現できないので、プロジェクトを添付していただけますか。
名無し 約13日前 2018年8月3日16:29
すみません、こちらの確認間違いです。お手数かけました。
空欄のフォームがあった場合それは登録されていません。

お忙しい中恐れ入りますが、以下ご教示頂けますでしょうか。
formsetでは動的なフォーム(フィールド)の変更処理は可能でしょうか?
(私はJavaScriptについてはあまり知見がないです。。)
例えば、
extra=3のとき、4つ目を入力したい時にフォームレコードを1つ追加したり、
大項目・中項目があった場合に、大項目のプルダウンメニュー選択によって中項目が変わり、
上記を反映した形で登録、編集処理できるといったことです。
なりと 約12日前 2018年8月4日15:37
モデルフォームセットやインラインフォームセットでのフォームの追加は
https://torina.top/detail/468/
のような感じになります。

中項目を大項目のプルダウン選択で変えたい場合は
https://torina.top/detail/242/
のような感じになります。
Ajaxを使う方法もありますが、あらかじめhtml内に
大項目1:[中項目1, 中項目2, ...]
大項目2: [中項目......]
のような配列などで定義しておき、大項目が変更されたらそれに紐づく中項目を<select>内の<option>などに設定します。

どちらも自力で行うとJavaScriptの知識は多少必要になります。何か便利なライブラリもあるかもしれませんが、この手のは使ったことがないので具体亭なライブラリ名はわからないです。
名無し 約10日前 2018年8月6日13:50 返信する
ありがとうございます!
対象リンクを参考にさせて頂きます。
JavaScriptもやはり必要なんですね。
ちょこちょこ勉強するようにします。