DjangoでInlineFormSetに独自のバリデーションを設定する

## 概要

最近Djangoで紐づいた2つのモデルをInlineFormSetを使ってデータを作ることがあったのですが、その時にカスタムバリデーションを組むのに手間取ってしまったので、忘備録として投稿します。 

## 環境 

- MacOS Monterey
- Python 3.11.2
- Django 4.2
- Poetry 1.4.1

## 参照 


コードを直接見たい人は[こちらのGitHub](https://github.com/joshua-hashimoto/blog-django-formset)を確認してください。 
特にテンプレートの組み方などを知りたい方はこちらの記事では詳細は書きませんのでコードの確認をお願いいたします。

## 短いバージョン 

コードだけ知りたい、という方はこちらを見ていただければと思います。 

```py:books/forms.py
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](https://bulma.io)で、編集・削除・FormSetの追加などは今回省きます。 

![django-formset_アプリケーション.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/147238/7724718b-dc9e-fb23-dd9f-98326603ffbd.png)

### models.py

モデルは作家とそれに紐づく本の簡単なものになります。 

```py:books/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を設定することで独自のバリデーションを設定することができます。

```py:books/forms.py
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`引数に設定することで実際にバリデーションが行われるようになります。

![ModelForm_error.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/147238/69ab59b3-1010-0928-22db-acadcc837311.png)

#### Form全体のバリデーション

全体バリデーションを利用する場合は`forms.BaseInlineFormSet`を継承したクラスを作成して、それを`forms.inlineformset_factory`の`formset`引数にそのクラスを渡すことで独自のバリデーションを設定することができます。

```py:books/forms.py
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エラーとしては登録されないので、その取り出し方と表示の仕方は考慮が必要です。

```django:templates/books/forms.html
{% 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 %}
```

![BaseInlineFormSet_error.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/147238/fae9cc44-83ac-e870-e264-b4b6578647c7.png)


単体バリデーションは各Formに対してエラーを発生させられるのに対して、`forms.BaseInlineFormSet`を使ったバリデーションはユーザーからPOSTされた複数のFormにアクセスできるため、それぞれの値を比較してバリデーションを行えるのが特徴です。 


例えば一番使えそうな例として挙げられるのが、FormSetの各Formがパーセントフィールドを持っていて、これの合計が100%であるかどうかなどです。

#### views.py

`views.py`では 通常のFormSetとして使用できます。

```py:books/views.py
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)
```