概要
Django の View で、モデルとは関係ない一連のデータ を編集できるようにしたい時があります。
TemplateView を継承して作り込んでも良いのですが、バリデーションの事などを考えると FormView を継承して FormSet を使うのが良さそうです。
さらに、構造の異なるデータを扱う場合は1個の View で複数の FormSet を扱いたいです。
この記事では、1個の View で複数の FormSet を扱い、編集結果のバリデーションと保存をする 方法についてまとめます。
動作確認したバージョン
- Python: 3.11.4
- Django: 4.2.7
記事の書き方について
- あるクラスのインスタンスのことを、そのクラスの「オブジェクト」と表現することがあります。
- あるクラスのサブクラスやそのオブジェクトについて、単に元のクラス名で表現することがあります。
たとえば、FormSet のサブクラスやそのオブジェクトについて「この FormSet を~」等と書くことがあります。
ゴール
- モデルと関係ない一連のデータをファイルから取得して、その編集画面を作る
- 構造の異なる複数の「一連のデータ」を同じ画面で編集できるようにする
- FormView と同様にバリデーションと保存の処理をする
この記事でやらないこと
- 「一連のデータ」の中の個々のデータを追加したり削除したりすること
抽象的なイメージ
具体的なデータと画面
この記事では具体例として、「職歴」と「学歴」をそれぞれ「一連のデータ」として扱うことにします。
前提知識
この記事は 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. クラスの定義
- 扱うデータの構造に合わせて複数の FormSet を作る。
- FormView のサブクラスを作る。
2. GET リクエストの処理
- FormView で
get_context_data()
をオーバーライドして以下の処理を作る。- ファイルから取得したデータで FormSet を初期化する。
- FormSet を template に渡す (context に含める)。
- template を作る。
3. POST リクエストの処理
- POST データから編集後の FormSet を取得する。
- 「バリデーションを通ったら保存処理をする」という流れを作る。
- 保存処理を作る。
クラスの定義
Django プロジェクトに適当なアプリケーションを作って、その中の forms.py と views.py を編集します。
FormSet の作成
FormSet を作るには、まず1個のデータを扱う Form を作ります。
ここでは「職歴」1個分の Form を以下のように定義します。
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 のつづき'''
from django.forms import formset_factory
# 職歴のフォームセット
# 修正用のフォームなので追加はできなくて良い (extra=0)
JobHistoryFormSet = formset_factory(JobHistoryForm, extra=0)
引数の extra
は項目を追加するためのもの (追加用フォームの個数) なので、ここでは0としておきます (デフォルトは1です)。
同様に、「学歴」の Form と FormSet も作ります。
'''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 を編集して、このビューを表示できるようにしておいてください。
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
に初期値を入れるところだけ書きます。
初期値を取得する関数もあとで書きます。返り値は辞書になる想定です。
'''views.py に import を追加'''
from .forms import JobHistoryFormSet, EducationHistoryFormSet
'''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 ファイルを作って、これを読み書きするようにします。
{
"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 に import を追加'''
import json
from django.conf import settings
'''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
に入れたので、これを参照します。
{# 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 を書けば、より柔軟な出力ができます。
-
これで、編集画面を表示するところまでができました。
POST リクエストの処理
POST リクエストの処理の流れを整理しておきます。
-
post()
が呼ばれるので、その中で以下の処理をする。- POST データを参照して、編集後のデータが入った FormSet を作る。
- 各 FormSet の
is_valid()
を呼んでバリデーションをする。 - バリデーションが通ったら
form_valid()
を呼ぶ。 - バリデーションが通らなければ
form_invalid()
を呼ぶ。
-
form_valid()
の中で以下の処理をする。- 編集後のデータを保存する。
-
success_url
へのリダイレクトを返す。
-
form_invalid()
の中で以下の処理をする。- バリデーション実施後の (エラーのある) FormSet で編集画面を再表示する。
get_context_data()
編集後の FormSet を作る処理は post()
の中で書かなくても、get_context_data()
を呼べば良いです。
GET 処理の時にとりあえず data
を None
にしていましたが、これを POST リクエストの時は POST データから取るようにします。
'''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 の 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 の 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 の HistoryEditView の中'''
def form_invalid(self, context):
return self.render_to_response(context)
form_invalid()
もスーパークラスでは Form を引数に取るようになっています が、ここでは context
を引数にしています。
こうすることで編集画面を簡単に再描画できるからです。
post()
から渡された context
には、すでにエラーの情報も入っています。
下図はそれぞれの FormSet に間違った入力をして送信した結果です。
編集画面が再表示され、それぞれのエラー情報が表示されています (色を変えたり日本語にしたりした方が良いですが)。