3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Django で複数の FormSet を扱う

Last updated at Posted at 2023-11-22

概要

Django の View で、モデルとは関係ない一連のデータ を編集できるようにしたい時があります。
TemplateView を継承して作り込んでも良いのですが、バリデーションの事などを考えると FormView を継承して FormSet を使うのが良さそうです。
さらに、構造の異なるデータを扱う場合は1個の View で複数の FormSet を扱いたいです。

この記事では、1個の View で複数の FormSet を扱い、編集結果のバリデーションと保存をする 方法についてまとめます。

動作確認したバージョン

  • Python: 3.11.4
  • Django: 4.2.7

記事の書き方について

  • あるクラスのインスタンスのことを、そのクラスの「オブジェクト」と表現することがあります。
  • あるクラスのサブクラスやそのオブジェクトについて、単に元のクラス名で表現することがあります。
    たとえば、FormSet のサブクラスやそのオブジェクトについて「この FormSet を~」等と書くことがあります。

ゴール

  • モデルと関係ない一連のデータをファイルから取得して、その編集画面を作る
  • 構造の異なる複数の「一連のデータ」を同じ画面で編集できるようにする
  • FormView と同様にバリデーションと保存の処理をする

この記事でやらないこと

  • 「一連のデータ」の中の個々のデータを追加したり削除したりすること

抽象的なイメージ

01_image.png

具体的なデータと画面

この記事では具体例として、「職歴」と「学歴」をそれぞれ「一連のデータ」として扱うことにします。

04_image2.png

前提知識

この記事は FormView と FormSet について知っている前提で書いていますが、簡単におさらいします。

FormView

  • FormView は TemplateView のサブクラスみたいなもの (同じ Mixin を継承している) なので、HTML で template を書いて context でデータを埋め込みます。
  • FormView の場合はこの context に 'form' というエントリが自動的に追加され、template 側でこれを使って編集用のフォームを出力します。
  • 'form' エントリには Form (のサブクラス) のオブジェクトが入るようになっています。
  • つまり、単品のデータであれば簡単に編集用のフォームを作れるようになっています。
  • POST データから編集後の Form を得る仕組みや、Form の機能を使ってバリデーションをする仕組みも用意されています。

FormSet

  • FormSet は「一連のデータ」を扱えるようにした Form のようなものです。
  • FormSet は複数の Form をリストのように扱えるようになっています。
  • FormSet を context の1個のエントリとして template に渡すことができます。
  • template 側で、for ループを回すことで FormSet が持っている各 Form を描画できます。

実装プラン

実装のおおまかな流れです。
次のチャプターから、この流れに沿って実装していきます。

1. クラスの定義

  1. 扱うデータの構造に合わせて複数の FormSet を作る。
  2. FormView のサブクラスを作る。

2. GET リクエストの処理

  1. FormView で get_context_data() をオーバーライドして以下の処理を作る。
    1. ファイルから取得したデータで FormSet を初期化する。
    2. FormSet を template に渡す (context に含める)。
  2. template を作る。

3. POST リクエストの処理

  1. POST データから編集後の FormSet を取得する。
  2. 「バリデーションを通ったら保存処理をする」という流れを作る。
  3. 保存処理を作る。

クラスの定義

Django プロジェクトに適当なアプリケーションを作って、その中の forms.py と views.py を編集します。

FormSet の作成

FormSet を作るには、まず1個のデータを扱う Form を作ります。
ここでは「職歴」1個分の Form を以下のように定義します。

forms.py
from django import forms

class JobHistoryForm(forms.Form):
    '''職歴のフォーム
    '''
    job_title = forms.CharField(label='職種')
    position = forms.CharField(label='職務')
    start_date = forms.DateField(label='入社年月日')
    end_date = forms.DateField(label='退社年月日')

このように定義するだけで、各フィールドが空でないことや、日付のフィールドが日付形式で入力されていることを検証するようになります。
これを元に FormSet を作るには、formset_factory() を使います。

forms.py
'''forms.py のつづき'''
from django.forms import formset_factory

# 職歴のフォームセット
# 修正用のフォームなので追加はできなくて良い (extra=0)
JobHistoryFormSet = formset_factory(JobHistoryForm, extra=0)

引数の extra は項目を追加するためのもの (追加用フォームの個数) なので、ここでは0としておきます (デフォルトは1です)。

同様に、「学歴」の Form と FormSet も作ります。

forms.py
'''forms.py のつづき'''
class EducationHistoryForm(forms.Form):
    '''学歴のフォーム
    '''
    school_name = forms.CharField(label='学校名')
    department = forms.CharField(label='学科')
    graduation_date = forms.DateField(label='卒業年月日')

# 学歴のフォームセット
# 修正用のフォームなので追加はできなくて良い (extra=0)
EducationHistoryFormSet = formset_factory(EducationHistoryForm, extra=0)

FormView の作成

FormView を継承してカスタムビューを作ります。
urls.py を編集して、このビューを表示できるようにしておいてください。

views.py
from django.views.generic.edit import FormView

class HistoryEditView(FormView):
    template_name = 'app/history_edit.html'
    success_url = '/'

GET リクエストの処理

get_context_data()

get_context_data() をオーバーライドして、template に渡すデータの中に FormSet を追加します。
FormSet のコンストラクタには以下の引数が必要です。

  • data: POST リクエストだった場合の POST データ
  • prefix: Form を見分けるための接頭辞
  • initial: POST データが無かった場合の初期値

とりあえず dataNone にして、initial に初期値を入れるところだけ書きます。
初期値を取得する関数もあとで書きます。返り値は辞書になる想定です。

views.py
'''views.py に import を追加'''
from .forms import JobHistoryFormSet, EducationHistoryFormSet
views.py
'''views.py の HistoryEditView の中'''
    def get_context_data(self, **kwargs):
        # form を使わないので get_form() を呼ばれないよう None を入れておく
        context = super().get_context_data(form=None, **kwargs)

        # FormSet に渡す POST リクエストからのデータ
        # とりあえず None にしておいて、あとで修正する
        data = None

        # 初期値を取得する
        initial = self.get_initial_data()  # この関数はあとで書く

        # 職歴のフォームセット
        context['job_history_formset'] = JobHistoryFormSet(
            data=data,
            prefix='jobhistory',
            initial=initial['job_history'],
        )

        # 学歴のフォームセット
        context['education_history_formset'] = EducationHistoryFormSet(
            data=data,
            prefix='educationhistory',
            initial=initial['education_history'],
        )

        return context

スーパークラスの get_context_data() を呼ぶときに form=None にしているのは、form をセットすると get_form() を呼ばれてしまうからです。
get_form()form_class 属性を参照して自動で Form を作ろうとするので、form_class をセットしていない現状ではエラーになってしまうのです。

これをする代わりに、get_form() をオーバーライドして (None を返すようにして) エラーを出さないという方法もあります。

prefix は FormSet (または Form) ごとにユニークになっていれば良いです。
HTML 上では1個の <form> タグの中に複数の FormSet/Form を描画するので、各フィールド名に接頭辞をつけて見分けるようにしているのです。

FormSet の初期値を取得する

Django のプロジェクトフォルダに以下のような JSON ファイルを作って、これを読み書きするようにします。

data.json
{
    "job_history": [
        {
            "job_title": "ゲーム制作",
            "position": "プランナー",
            "start_date": "2020-04-01",
            "end_date": "2021-03-31"
        },
        {
            "job_title": "Web 製作",
            "position": "テスター",
            "start_date": "2021-04-01",
            "end_date": "2022-02-28"
        }
    ],
    "education_history": [
        {
            "school_name": "A 高校",
            "department": "普通科",
            "graduation_date": "2016-03-31"
        },
        {
            "school_name": "B 大学",
            "department": "工学部計算機科学科",
            "graduation_date": "2020-03-31"
        }
    ]
}

ファイルの内容を取得する関数は以下のようになります。

views.py
'''views.py に import を追加'''
import json
from django.conf import settings
views.py
'''views.py の HistoryEditView の中'''
    def get_initial_data(self):
        data_path = settings.BASE_DIR / 'data.json'
        with open(data_path, encoding='utf-8') as f:
            data = json.load(f)
        return data

関数名を 'get_initial' にしない方が良いです。
get_initial() はすでにスーパークラスで 定義 されていて、用途が違うからです。

template を作る

HistoryEditView で使う template を作ります。
「職歴」と「学歴」の FormSet は、それぞれ 'job_history_formset', 'education_history_formset' という名前で context に入れたので、これを参照します。

templates/app/history_edit.html
{# template の form 部分 #}
  <form method="post">
    {% csrf_token %}

    <h2>職歴</h2>

    {{ job_history_formset.management_form }}
    {% for form in job_history_formset %}
      <table>
        {{ form.as_table }}
      </table>
    {% endfor %}

    <h2>学歴</h2>

    {{ education_history_formset.management_form }}
    {% for form in education_history_formset %}
      <table>
        {{ form.as_table }}
      </table>
    {% endfor %}

    <input type="submit" value="保存">
  </form>

以下、補足です。

  • FormSet の management_form は状態を管理するための隠しフィールドを出力するものなので、必ず書くようにします。
  • for 文の中の form は単品の Form と同じです。
    • {{ form.as_table }} の代わりに form の属性やフィールドを参照して HTML を書けば、より柔軟な出力ができます。

これで、編集画面を表示するところまでができました。

02_edit_page.png

POST リクエストの処理

POST リクエストの処理の流れを整理しておきます。

  1. post() が呼ばれるので、その中で以下の処理をする。
    1. POST データを参照して、編集後のデータが入った FormSet を作る。
    2. 各 FormSet の is_valid() を呼んでバリデーションをする。
    3. バリデーションが通ったら form_valid() を呼ぶ。
    4. バリデーションが通らなければ form_invalid() を呼ぶ。
  2. form_valid() の中で以下の処理をする。
    1. 編集後のデータを保存する。
    2. success_url へのリダイレクトを返す。
  3. form_invalid() の中で以下の処理をする。
    1. バリデーション実施後の (エラーのある) FormSet で編集画面を再表示する。

get_context_data()

編集後の FormSet を作る処理は post() の中で書かなくても、get_context_data() を呼べば良いです。
GET 処理の時にとりあえず dataNone にしていましたが、これを POST リクエストの時は POST データから取るようにします。

views.py
'''views.py の HistoryEditView の中'''
    def get_context_data(self, **kwargs):

        # - 中略 -

        # FormSet に渡す POST リクエストからのデータ
        # リクエストが POST でなければ None にしておく
        data = self.request.POST if self.request.method == 'POST' else None

        # - 後略 -

ここで、「職歴」の FormSet にも「学歴」の FormSet にも同じ data を入れていたことが気になるかも知れませんが、問題ありません。
各 FormSet は prefix を使って自分に関係あるフィールドだけを使うからです。

post()

post() をオーバーライドします。
編集後の FormSet の取得には、上で書いた get_context_data() を使います。

views.py
'''views.py の HistoryEditView の中'''
    def post(self, request, *args, **kwargs):
        # 編集後のフォームセットを取得する
        context = self.get_context_data()
        job_history_formset = context['job_history_formset']
        education_history_formset = context['education_history_formset']

        # バリデーションの実施
        job_history_valid = job_history_formset.is_valid()
        education_history_valid = education_history_formset.is_valid()

        # バリデーションがすべて成功なら保存処理をする
        if job_history_valid and education_history_valid:
            return self.form_valid(
                job_history_formset, education_history_formset)

        # さもなくばエラーを表示する
        return self.form_invalid(context)

ここで、form_valid() の呼び方に注意してください。
普通にオーバーライドした場合、form_valid() の引数は1個で その値は Form オブジェクト になります。
それだと今回の目的に使えないので、引数を変えているのです。

だったら form_valid() をオーバーライドして使わなくても、別の関数 ('forms_valid' など) を作れば良いではないか、という考え方もあります。

form_invalid() もスーパークラスのものとは引数を変えてあります。
こうした方が良い理由はあとで分かります。

保存処理と画面遷移

form_valid() をオーバーライドして保存処理と画面遷移をします。

views.py
'''views.py の HistoryEditView の中'''
    def form_valid(
            self, job_history_formset, education_history_formset):

        # 編集後のフォームセットから職歴を取り出す
        job_history = []
        for job_history_form in job_history_formset:
            new_data = job_history_form.cleaned_data

            # date 型を文字列にする
            new_data['start_date'] = new_data['start_date'].strftime('%Y-%m-%d')
            new_data['end_date'] = new_data['end_date'].strftime('%Y-%m-%d')

            job_history.append(new_data)

        # 編集後のフォームセットから学歴を取り出す
        education_history = []
        for education_history_form in education_history_formset:
            new_data = education_history_form.cleaned_data

            # date 型を文字列にする
            new_data['graduation_date'] =\
                new_data['graduation_date'].strftime('%Y-%m-%d')

            education_history.append(new_data)

        # 辞書にする (これを JSON にして保存する)
        data = {
            'job_history': job_history,
            'education_history': education_history,
        }

        # JSON ファイルに保存する
        data_path = settings.BASE_DIR / 'data.json'
        with open(data_path, 'w', encoding='utf-8') as f:
            json.dump(data, f, ensure_ascii=False, indent=4)

        # success_url へ遷移する
        return super().form_valid(None)

for ループで FormSet から個々の Form の cleaned_data を取り出して、それらの辞書をリストにしています。
JSON にする都合上、文字列に自動変換されない値は文字列にする必要があります。

画面遷移は、単にスーパークラスの form_valid() を呼んでいます。
これには Form を渡すことになっていますが、何もしないので None で大丈夫です。

エラー表示処理

バリデーションに失敗した場合は form_invalid() が呼ばれるようになっているので、これをオーバーライドします。

views.py
'''views.py の HistoryEditView の中'''
    def form_invalid(self, context):
        return self.render_to_response(context)

form_invalid() もスーパークラスでは Form を引数に取るようになっています が、ここでは context を引数にしています。
こうすることで編集画面を簡単に再描画できるからです。
post() から渡された context には、すでにエラーの情報も入っています。

下図はそれぞれの FormSet に間違った入力をして送信した結果です。
編集画面が再表示され、それぞれのエラー情報が表示されています (色を変えたり日本語にしたりした方が良いですが)。

05_validation_errors.png

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?