Djangoのフォームウィザードで単一モデルオブジェクトを登録・更新(django-formtools 2.2)

Djangoでモデルオブジェクトの登録・更新機能は、汎用ビューであるCreateViewUpdateViewで簡単に実装することができます。
しかし扱うモデルフィールドが多い場合、これらのビューは1つの画面に多数のフォームを描画するために使用性が低くなりがちです。

そこで本エントリでは、このような課題を django-formtools のフォームウィザードで解決していきます。

github.com

フォームウィザードの完成イメージがこちらです。
ページ切り替えの都度、フォームバリデーションを実行し、中間データはセッションに保存されます。

f:id:hiroki-sawano:20210127173048g:plain

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を使用していきます。

from formtools.wizard.views import SessionWizardView

class MyWizardView(SessionWizardView):
    ...

新規作成フォームウィザード

SessionWizardViewを継承し、次の実装を持つ汎用クラスビュー WizardCreateView を実装します。

  • file_storageFileFieldを扱う場合に、中間ファイルの保存先を指定する設定です。例では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(...)

続いて、urlpatternspath()を追加します。
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つのフォームが表示されます。

f:id:hiroki-sawano:20210126232251p:plain

値を入力せずに「次へ」でフォームを送信すると、フォームバリデーションが実行され、必須項目 「項目1」がエラーとなります。

f:id:hiroki-sawano:20210126232320p:plain

各フォームに適当な値を入力し、再度フォームを送信します。

f:id:hiroki-sawano:20210126232355p:plain

正常なフォームを送信したため、次のページに遷移し、 Form1 が表示されました。

f:id:hiroki-sawano:20210126232439p:plain

この時、送信した Form0 の値はセッションに保存されています。
アップロードしたファイルは一時的に WizardCreateView.file_storageで指定したパスに保存されます。

$ ls media/temp/
test.txt

「戻る」から前のページに戻るとセッションに保存された情報が読み込まれます。
ファイルはフォーム上表示されていませんが、実際には中間ファイルとして保存されているので、そのまま進んでもちゃんと登録されます(関連Issue)。

f:id:hiroki-sawano:20210126232613p:plain

次のページに再度進み、「項目3」のみ値を入力して「登録」します。

f:id:hiroki-sawano:20210126232728p:plain

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> にアクセスして先ほど登録したデータを更新します。

f:id:hiroki-sawano:20210126233908p:plain

試しに「項目1」を空にして、フォームバリデーションが新規作成時と同様に機能することを確認します。

f:id:hiroki-sawano:20210126233952p:plain

「項目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>

参考