概要
最近Djangoで紐づいた2つのモデルをInlineFormSetを使ってデータを作ることがあったのですが、その時にカスタムバリデーションを組むのに手間取ってしまったので、忘備録として投稿します。
環境
- MacOS Monterey
- Python 3.11.2
- Django 4.2
- Poetry 1.4.1
参照
コードを直接見たい人はこちらのGitHubを確認してください。
特にテンプレートの組み方などを知りたい方はこちらの記事では詳細は書きませんのでコードの確認をお願いいたします。
短いバージョン
コードだけ知りたい、という方はこちらを見ていただければと思います。
from django import forms
from .models import ParentModel, ChildModel
class ParentModelForm(forms.ModelForm):
class Meta:
model = ParentModel
fields = (
"property",
)
def clean_property(self):
cleaned_data = self.cleaned_data
property = cleaned_data.get("property", None)
# 親側のフィールドに対するバリデーションはここ
return property
class ChildModelForm(forms.ModelForm):
class Meta:
model = ChildModel
fields = (
"property",
)
def clean_property(self):
cleaned_data = self.cleaned_data
property = cleaned_data.get("property", None)
# InlineFormSetの1つ1つのFormの各フィールドに対してバリデーションをかける場合は通常のFormのバリデーションと同じように設定する
return property
class ChildBaseFormset(forms.BaseInlineFormSet):
def clean(self):
formset = self.forms # formsetのリストはformsとして取得できる
# formset全体でバリデーションをかけたい場合はここ
# 例えば、propertyが同じ内容でないことを確認する場合は以下の感じでバリデーションを行える
# 値が存在するformだけを取り出す
filtered_forms = list(filter(lambda form: form.cleaned_data.get("property"), formset))
# 値を取り出す
properties = list(map(lambda form: form.cleaned_data.get("property"), filtered_forms))
# 同一の値があればsetは1になり、その数がformsの数より多い場合はTrue
has_same_value = len(set(properties)) == 1 and len(filtered_forms) > 1
# 同一の値が存在する場合はエラーを発生させる
if has_same_value:
raise forms.ValidationError("同じ値が存在するようです。")
ChildFormSet = forms.inlineformset_factory(
ParentModel,
ChildModel,
form=ChildModelForm, # それぞれのFormをカスタマイズしたい場合はformにセットする
formset=ChildBaseFormset, # カスタマイズされたFormSetを使いたい場合はformsetにセットする
can_delete=True,
extra=3
)
長いバージョン
作家とその人が書いた本を保存できるアプリケーションを題材として具体的な例を見ていきましょう。
概要
作るものは簡単で、作家とその作家の著書をInlineFormSetで入力して保存できる簡単なものです。
保存された値は入力欄の下に追加されていきます。
アプリケーション作成が目的ではないので、デザインはBulmaで、編集・削除・FormSetの追加などは今回省きます。
models.py
モデルは作家とそれに紐づく本の簡単なものになります。
from django.db import models
class Author(models.Model):
name = models.CharField(max_length=128)
birthday = models.DateField(null=True, blank=True)
def __str__(self):
return self.name
class Book(models.Model):
RATES = [
(1, "Very Bad"),
(2, "Bad"),
(3, "Normal"),
(4, "Good"),
(5, "Very Good"),
]
author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="books")
name = models.CharField(max_length=250)
published = models.DateField()
rate = models.IntegerField(choices=RATES)
def __str__(self):
return self.name
作家は名前と生年月日、本の方は作家・本のタイトル・出版日・評価だけです。
forms.py
こちらが本題ですね。
短いバージョンのコード内のコメントと同一の説明になってしまいますが、上から説明していきます。
まず、InlineFormSetというのは親子関係にある2つのモデルのFormの繋げている状態でかつ、子のFormを複数表示したい時に使用します。
InlineFormSetに独自のバリデーションを設定したい場合は、2つパターンがあります。
- Form単体のバリデーション
- Form全体のバリデーション
ここでは両方のパターンを見ていきます。
Form単体のバリデーション
InlineFormSetの各Formにバリデーションを設定したい場合は通常のFormのバリデーションと同じです。
これを適応するには、forms.inlineformset_factory
のform
引数に作成したFormを設定することで独自のバリデーションを設定することができます。
from datetime import datetime as dt
from django import forms
from .models import Author, Book
def validate_date_is_before_today(value):
today = dt.now()
if today.date() <= value:
raise forms.ValidationError("今日より前に設定してください。")
return value
class AuthorForm(forms.ModelForm):
class Meta:
model = Author
fields = (
"name",
"birthday",
)
def clean_birthday(self):
cleaned_data = self.cleaned_data
birthday = cleaned_data.get("birthday", None)
return validate_date_is_before_today(birthday)
class BookForm(forms.ModelForm):
class Meta:
model = Book
fields = (
"name",
"published",
"rate",
)
def clean_published(self):
cleaned_data = self.cleaned_data
published = cleaned_data.get("published", None)
return validate_date_is_before_today(published)
BookFormset = forms.inlineformset_factory(
Author,
Book,
form=BookForm,
can_delete=True,
extra=3
)
上記の例では、本を保存するための各Formのpublished
の値が今日以降だった場合はエラーが発生するように設定しています。これをforms.inlineformset_factory
のform
引数に設定することで実際にバリデーションが行われるようになります。
Form全体のバリデーション
全体バリデーションを利用する場合はforms.BaseInlineFormSet
を継承したクラスを作成して、それをforms.inlineformset_factory
のformset
引数にそのクラスを渡すことで独自のバリデーションを設定することができます。
from datetime import datetime as dt
from django import forms
from .models import Author, Book
def validate_date_is_before_today(value):
today = dt.now()
if today.date() <= value:
raise forms.ValidationError("今日より前に設定してください。")
return value
class AuthorForm(forms.ModelForm):
class Meta:
model = Author
fields = (
"name",
"birthday",
)
def clean_birthday(self):
cleaned_data = self.cleaned_data
birthday = cleaned_data.get("birthday", None)
return validate_date_is_before_today(birthday)
class BookBaseFormset(forms.BaseInlineFormSet):
def get_filled_forms(self, forms):
filtered = filter(lambda form: form.cleaned_data.get("name"), forms)
return list(filtered)
def get_names(self, forms):
names = map(lambda form: form.cleaned_data.get("name"), forms)
return list(names)
def clean(self):
filtered_forms = self.get_filled_forms(self.forms)
names = self.get_names(filtered_forms)
has_same_book_name = len(set(names)) == 1 and len(filtered_forms) > 1
if has_same_book_name:
raise forms.ValidationError("同じタイトルの本が存在するようです。")
BookFormset = forms.inlineformset_factory(
Author,
Book,
formset=BookBaseFormset,
fields=("name", "published", "rate",),
can_delete=True,
extra=3
)
気をつけないといけないこととして、forms.BaseInlineFormSet
のclean
で発生したエラーは、テンプレート側でformset.non_form_errors
の中に格納されるということです。各Formのバリデーションと違って、Formエラーとしては登録されないので、その取り出し方と表示の仕方は考慮が必要です。
{% if formset.non_form_errors %}
{% for error in formset.non_form_errors %}
<article class="message is-danger mb-2">
<div class="message-body">
{{ error }}
</div>
</article>
{% endfor %}
{% endif %}
単体バリデーションは各Formに対してエラーを発生させられるのに対して、forms.BaseInlineFormSet
を使ったバリデーションはユーザーからPOSTされた複数のFormにアクセスできるため、それぞれの値を比較してバリデーションを行えるのが特徴です。
例えば一番使えそうな例として挙げられるのが、FormSetの各Formがパーセントフィールドを持っていて、これの合計が100%であるかどうかなどです。
views.py
views.py
では 通常のFormSetとして使用できます。
from django.urls import reverse_lazy
from django.views.generic import CreateView
from .forms import AuthorForm, BookFormset
from .models import Author, Book
class CreateBookView(CreateView):
model = Author
form_class = AuthorForm
template_name = "books/forms.html"
success_url = reverse_lazy("books:create")
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["formset"] = BookFormset(self.request.POST or None)
context["object_list"] = Author.objects.all()
return context
def form_valid(self, form):
context = self.get_context_data(form=form)
formset = context.get("formset", None)
if formset is None:
return super().form_invalid(form)
if formset.is_valid():
response = super().form_valid(form)
formset.instance = self.object
formset.save()
return response
return super().form_invalid(form)