Django で複数の FormSet を扱う

# 概要

[Django](https://www.djangoproject.com) の View で、**モデルとは関係ない一連のデータ** を編集できるようにしたい時があります。  
[TemplateView](https://docs.djangoproject.com/ja/4.2/ref/class-based-views/base/#templateview) を継承して作り込んでも良いのですが、バリデーションの事などを考えると [FormView](https://docs.djangoproject.com/ja/4.2/ref/class-based-views/generic-editing/#formview) を継承して [FormSet](https://docs.djangoproject.com/ja/4.2/topics/forms/formsets/) を使うのが良さそうです。  
さらに、構造の異なるデータを扱う場合は1個の View で複数の FormSet を扱いたいです。

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

## 動作確認したバージョン

- Python: 3.11.4
- Django: 4.2.7

## 記事の書き方について

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

# ゴール

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

この記事でやらないこと

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

## 抽象的なイメージ

![01_image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/95b142b4-9808-c89a-47eb-33f6be68905d.png)

## 具体的なデータと画面

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

![04_image2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/284c72c6-795c-84b9-db33-705280f3cc07.png)

# 前提知識

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

## FormView

- [FormView](https://docs.djangoproject.com/ja/4.2/ref/class-based-views/generic-editing/#formview) は TemplateView のサブクラスみたいなもの (同じ Mixin を継承している) なので、HTML で template を書いて `context` でデータを埋め込みます。
- FormView の場合はこの `context` に 'form' というエントリが自動的に追加され、template 側でこれを使って編集用のフォームを出力します。
- 'form' エントリには [Form](https://docs.djangoproject.com/ja/4.2/topics/forms/#forms-in-django) (のサブクラス) のオブジェクトが入るようになっています。
- つまり、単品のデータであれば簡単に編集用のフォームを作れるようになっています。
- POST データから編集後の Form を得る仕組みや、Form の機能を使ってバリデーションをする仕組みも用意されています。

## FormSet

- [FormSet](https://docs.djangoproject.com/ja/4.2/topics/forms/formsets/) は「一連のデータ」を扱えるようにした Form のようなものです。
- FormSet は複数の Form をリストのように扱えるようになっています。
- FormSet を `context` の1個のエントリとして template に渡すことができます。
- template 側で、`for` ループを回すことで FormSet が持っている各 Form を描画できます。

# 実装プラン

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

**1. クラスの定義**

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

**2. GET リクエストの処理**

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

**3. POST リクエストの処理**

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

# クラスの定義

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

## FormSet の作成

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

```python: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()](https://docs.djangoproject.com/ja/4.2/ref/forms/formsets/#django.forms.formsets.formset_factory) を使います。

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

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

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

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

```python: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 を編集して、このビューを表示できるようにしておいてください。

```python: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 データが無かった場合の初期値

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

```python:views.py
'''views.py に import を追加'''
from .forms import JobHistoryFormSet, EducationHistoryFormSet
```

```python: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` をセットしていない現状ではエラーになってしまうのです。

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

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

## FormSet の初期値を取得する

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

```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"
        }
    ]
}
```

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

```python:views.py
'''views.py に import を追加'''
import json
from django.conf import settings
```

```python: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
```

:::note
関数名を 'get_initial' にしない方が良いです。
`get_initial()` はすでにスーパークラスで [定義](https://docs.djangoproject.com/ja/4.2/ref/class-based-views/mixins-editing/#django.views.generic.edit.FormMixin.get_initial) されていて、用途が違うからです。
:::

## template を作る

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

```django: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` は状態を管理するための隠しフィールドを出力するものなので、必ず書くようにします。
    - 参考: [ManagementForm を理解する](https://docs.djangoproject.com/ja/4.2/topics/forms/formsets/#understanding-the-managementform)
- `for` 文の中の `form` は単品の Form と同じです。
    - `{{ form.as_table }}` の代わりに `form` の属性やフィールドを参照して HTML を書けば、より柔軟な出力ができます。

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

![02_edit_page.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/c435f5db-bc00-c7fc-3471-9e4c4cdf1478.png)

# POST リクエストの処理

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

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

## get_context_data()

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

```python: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

        # - 後略 -
```

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

## post()

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

```python: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 オブジェクト](https://docs.djangoproject.com/en/4.2/ref/class-based-views/mixins-editing/#django.views.generic.edit.FormMixin.form_valid) になります。  
それだと今回の目的に使えないので、引数を変えているのです。

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

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

## 保存処理と画面遷移

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

```python: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()` が呼ばれるようになっているので、これをオーバーライドします。

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

`form_invalid()` もスーパークラスでは [Form を引数に取るようになっています](https://docs.djangoproject.com/en/4.2/ref/class-based-views/mixins-editing/#django.views.generic.edit.FormMixin.form_invalid) が、ここでは `context` を引数にしています。  
こうすることで編集画面を簡単に再描画できるからです。  
`post()` から渡された `context` には、すでにエラーの情報も入っています。

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

![05_validation_errors.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/c5637db1-53f0-51f5-ed41-c92d1fc531ce.png)