Djangoでモデルオブジェクトの登録・更新機能は、汎用ビューであるCreateViewやUpdateViewで簡単に実装することができます。
しかし扱うモデルフィールドが多い場合、これらのビューは1つの画面に多数のフォームを描画するために使用性が低くなりがちです。
そこで本エントリでは、このような課題を django-formtools
のフォームウィザードで解決していきます。
フォームウィザードの完成イメージがこちらです。
ページ切り替えの都度、フォームバリデーションを実行し、中間データはセッションに保存されます。
django-formtoolsのインストール
pipで django-formtools
をインストールします。
pip install django-formtools
執筆時点でバージョンは2.2です。
❯ pip show django-formtools Name: django-formtools Version: 2.2 ...
モデル
更新対象となるモデルを用意します。
フォームバリデーションおよびファイルアップロードの動きを確認するため、
char1
のみ入力必須、 file
のみFileFieldとしています。
from django.db import models class MyModel(models.Model): char1 = models.CharField('項目1', max_length=255) char2 = models.CharField('項目2', max_length=255, blank=True, null=True) char3 = models.CharField('項目3', max_length=255, blank=True, null=True) char4 = models.CharField('項目4', max_length=255, blank=True, null=True) file = models.FileField('ファイル', blank=True, null=True)
フォーム
フォームウィザードの各ページで表示するフォームをそれぞれModelFormとして作成します。
いずれもモデル MyModel
を扱うため、 Meta.model = MyModel
とし、 Meta.fields
に各ページで表示するフォームフィールドを指定します。
from django import forms from .models import MyModel class Form0(forms.ModelForm): class Meta: model = MyModel fields = ['char1', 'char2', 'file'] class Form1(forms.ModelForm): class Meta: model = MyModel fields = ['char3', 'char4']
テンプレート
新規登録・更新ウィザードの両方で利用するテンプレート wizard_form.html
を作成します。
Bootstrapとdjango-crispy-formsを使用した例です。
利用可能なコンテキストの詳細についてはこちらを参照してください。
{% load crispy_forms_tags %} ... {{ wizard.form.media }} ... <form method="post" enctype="multipart/form-data" novalidate> {% csrf_token %} {{ wizard.management_form }} {% if wizard.form.forms %} {{ wizard.form.management_form }} {% for form in wizard.form.forms %} {{ form|crispy }} {% endfor %} {% else %} {{ wizard.form|crispy }} {% endif %} <div class="row"> <div class="col-lg-12"> {% if wizard.steps.prev %} <button type="submit" class="btn btn-success pull-left" name="wizard_goto_step" value="{{ wizard.steps.prev }}"> 戻る </button> {% endif %} {% if wizard.steps.step1 < wizard.steps.count %} <button type="submit" class="btn btn-success pull-right"> 次へ </button> {% else %} <button type="submit" class="btn btn-success pull-right"> 登録 </button> {% endif %} </div> </div> </form>
ビュー
ビューはWizardViewのサブクラスとして実装します。
中間データの保存方法( WizardView.storage_name
)に応じて、次のクラスが用意されていますので、実際にはこちらを使うと良いです。
- サーバサイドのセッションに保存する場合:SessionWizardView (※セッションを有効化しておくこと)
- クッキーを使用する場合:CookieWizardView
以降の説明ではSessionWizardViewを使用していきます。
from formtools.wizard.views import SessionWizardView class MyWizardView(SessionWizardView): ...
新規作成フォームウィザード
SessionWizardViewを継承し、次の実装を持つ汎用クラスビュー WizardCreateView
を実装します。
- file_storage:FileFieldを扱う場合に、中間ファイルの保存先を指定する設定です。例ではMEDIA_ROOT以下の
temp
ディレクトリを指定しています。 - model:後述するURLのas_view()で更新対象となるモデルクラスを指定するフィールドです。
- done():最終ページのフォーム送信で、実際にモデルを更新する実装を持ちます。
import os from django.conf import settings from django.core.files.storage import FileSystemStorage from django.http import HttpResponseRedirect from formtools.wizard.views import SessionWizardView class WizardCreateView(SessionWizardView): file_storage = FileSystemStorage( location=os.path.join(settings.MEDIA_ROOT, 'temp') ) model = None def done(self, form_list, **kwargs): # 各フォームデータをマージしてモデルオブジェクトを作成 data = {} for form in form_list: data.update(form.cleaned_data) self.model.objects.create(**data) # 遷移先の指定 return HttpResponseRedirect(...)
続いて、urlpatternsにpath()を追加します。
as_view()には描画するフォームのクラスをリストで渡します。
使用するテンプレートはtemplate_nameに、
更新対象のモデルは model
に指定します。
from .forms import Form0, Form1 from .models import MyModel from .views import WizardCreateView urlpatterns = [ path( 'my-model/new', WizardCreateView.as_view( [Form0, Form1], template_name='wizard_form.html', model=MyModel, ), name='create' ), ]
my-model/new
にアクセスすると、 Form0
で定義した3つのフォームが表示されます。
値を入力せずに「次へ」でフォームを送信すると、フォームバリデーションが実行され、必須項目 「項目1」がエラーとなります。
各フォームに適当な値を入力し、再度フォームを送信します。
正常なフォームを送信したため、次のページに遷移し、 Form1
が表示されました。
この時、送信した Form0
の値はセッションに保存されています。
アップロードしたファイルは一時的に WizardCreateView.file_storage
で指定したパスに保存されます。
$ ls media/temp/ test.txt
「戻る」から前のページに戻るとセッションに保存された情報が読み込まれます。
ファイルはフォーム上表示されていませんが、実際には中間ファイルとして保存されているので、そのまま進んでもちゃんと登録されます(関連Issue)。
次のページに再度進み、「項目3」のみ値を入力して「登録」します。
MyModel
から登録したデータが取得できることを確認します。
>>> instance = MyModel.objects.first() >>> instance.char1 'aaa' >>> instance.char2 'bbb' >>> instance.char3 'ccc' >>> instance.char4 >>> instance.file <FieldFile: test.txt>
登録が完了すると media/temp
に保存されていた中間ファイルはMEDIA_ROOTに移動します。
ファイルアップロード後に登録を中断した場合には media/temp
にファイルが残ってしまうため、定期的なメンテナンスが必要となる点に注意してください。
$ ls media/ test.txt $ ls media/temp/
更新ウィザード
新規登録ウィザードと同様に、SessionWizardViewを継承し、次の実装を持つ汎用クラスビュー WizardUpdateView
を実装します。
- get_form_instance():URLパラメタ
pk
をキーに取得したMyModel
の更新対象オブジェクトを返却します。 - done():最終ページのフォーム送信で、実際にモデルを更新する実装を持ちます。
from django.db.models.fields.files import FieldFile class WizardUpdateView(SessionWizardView): file_storage = FileSystemStorage( location=os.path.join(settings.MEDIA_ROOT, 'temp') ) model = None def get_form_instance(self, step): pk = self.kwargs['pk'] return self.model.objects.get(pk=pk) def done(self, form_list, **kwargs): # 各フォームデータをマージ data = {} for form in form_list: data.update(form.cleaned_data) # 更新対象のオブジェクトを取得 instance = self.model.objects.get(pk=kwargs['pk']) # instance.update(**data)だとFileFieldのファイル保存がうまく機能しなかったため、save()を使用 for attr, value in data.items(): # FileFieldの場合はClearableFileInputでクリアするときにFalseがくるのでNoneを設定 if isinstance(getattr(instance, attr), FieldFile) and not value: setattr(instance, attr, None) else: setattr(instance, attr, value) instance.save() # 遷移先の指定 return HttpResponseRedirect(...)
URLを追加します。更新対象オブジェクトのPK値をパラメタとして渡すようにします。
urlpatterns = [ ... path( 'my-model/<int:pk>', WizardUpdateView.as_view( [Form0, Form1], template_name='wizard_form.html', model=MyModel, ), name='update' ), ]
my-model/<int:pk>
にアクセスして先ほど登録したデータを更新します。
試しに「項目1」を空にして、フォームバリデーションが新規作成時と同様に機能することを確認します。
「項目1」を aaa
から zzz
に変更し、「ファイル」のクリアにチェックを入れて(削除指示)、「次へ」を押下します。
次のページでは、「項目3」に ccc
が登録されています。
「項目3」を ddd
に変更し、「項目4」に eee
を入力して「登録」します。
これで MyModel
の既存データを更新することができました。
>>> instance = MyModel.objects.first() >>> instance.char1 'zzz' >>> instance.char2 'bbb' >>> instance.char3 'ddd' >>> instance.char4 'eee' >>> instance.file <FieldFile: None>