🔥

DjangoのModelFormで、ManyToMany/ForeignKeyのフィールドをCharFieldで入力する

2021/09/03に公開

N:Mのフィールドを文字列で入力したい

DjangoのModelでForeignKeyやManyToManyのフィールドを定義したとき、それをそのまま継承してModelFormを作成するとModelChoiceFieldだったりModelMultipleChoiceFieldが設定されます。
が、例えば「ユーザーに任意の値を入力させて、その値が既にある場合はリレーションを設定、その値がない場合はリレーション先にデータを登録してからリレーションを設定」というような処理をやりたい場合、選択式のフィールドだと何かと不便です。
この点でしばらく悩んだので、一応の解決策をメモしておきます。もしかしたらもっと良い解決策があるかもしれないので、ご存知の方はコメント等で教えていただけると幸いです。

結論

先に成功したコードだけ書きます。例として書籍情報を管理するアプリを作ります。
大まかな流れとしては

  1. リレーションを持つフィールドをModelから引き継がずFormで新たに定義
  2. (ManyToManyの場合)Formのバリデーションで入力値を整形
  3. Viewでのバリデーション成功時にget_or_create()して紐づけ

となっています。

models.py
class Author(models.Model):
    """著者情報モデル"""

    name = models.CharField(verbose_name="著者名", max_length=255)


class Publisher(models.Model):
    """出版社情報モデル"""

    name = models.CharField(verbose_name="出版社名", max_length=255)

	
class Book(models.Model):
    """書籍情報モデル"""

    title = models.CharField(verbose_name="書名", max_length=255, blank=False)
    author = models.ManyToManyField(Author, verbose_name="著者", blank=True, null=True)
    publisher = models.ForeignKey(
        Publisher, verbose_name="出版社", on_delete=models.PROTECT, blank=True, null=True
    )
forms.py
class BookCreateForm(forms.ModelForm):
    author = forms.CharField(label="著者", max_length=255, required=False)
    publisher = forms.CharField(label="出版社", max_length=255, required=False)
    
    class Meta:
        model = Book
        fields = (
            "title",
        )

    # カンマ区切りのauthorを受け取ってリストを返す
    def clean_author(self):
        author = self.cleaned_data.get("author")
	authors_list = author.split(",")
        return authors_list
	
views.py
class BookCreateView(generic.CreateView):
    """書籍登録画面のビュー"""

    model = Book
    template_name = "create.html"
    form_class = BookCreateForm

    def form_valid(self, form: ModelForm) -> HttpResponse:
        book = form.save(commit=False)
	authors_list = form.cleaned_data.get("author")
	publisher = form.cleaned_data.get("publisher")
	
	# publisherをBookに紐づけ
        if publisher:
            book.publisher, created = Publisher.objects.get_or_create(name=publisher)
        
        book.save() # 一旦save
	
	# authorをBookに紐づけ
	# ManyToManyのFieldにaddするにはidが振られている必要があるため、
	# save()した後に登録する
        if authors_list:
            for author in authors_list:
                book.author.add(Author.objects.get_or_create(name=author)[0])
	    
        form.save_m2m()
        return super().form_valid(form)

解説

まずはModelを定義します。
今回例に挙げるアプリは以下の3つのModelを持っています。

models.py
class Author(models.Model):
    name = models.CharField(verbose_name="著者名", max_length=255)


class Publisher(models.Model):
    name = models.CharField(verbose_name="出版社名", max_length=255)

	
class Book(models.Model):
    title = models.CharField(verbose_name="書名", max_length=255, blank=False)
    author = models.ManyToManyField(Author, verbose_name="著者", blank=True, null=True)
    publisher = models.ForeignKey(
        Publisher, verbose_name="出版社", on_delete=models.PROTECT, blank=True, null=True
    )

このModelを元にModelFormを作りますが、この時、authorとpublisherはBookから引き継がず、新たにFieldを定義します[1]。今回は普通の文字列を入力させたいので、author、publisherともにCharFieldとしています。
ManyToManyのauthorは複数の値が入力される可能性があるため、authorのバリデーション処理をオーバーライドし、入力値をカンマ区切りのリストにして返します。

forms.py
class BookCreateForm(forms.ModelForm):
    # authorとpublisherのFieldを新たに定義
    author = forms.CharField(label="著者", max_length=255, required=False)
    publisher = forms.CharField(label="出版社", max_length=255, required=False)
    
    class Meta:
        model = Book
	# authorとpublisherはfieldsに含めない
        fields = (
            "title",
        )

    # カンマ区切りのauthorを受け取ってリストを返す
    def clean_author(self):
        author = self.cleaned_data.get("author")
	authors_list = author.split(",")
        return authors_list

最後に、作成したフォームを使って登録処理を行うViewです。このViewではform_validメソッド内でget_or_create関数を用いて、該当するデータがあった場合はそのオブジェクトを、無ければ新規登録したオブジェクトを受け取り、Bookモデルに紐づけています。注意点として、ManyToManyFieldにaddする際にBookモデルにidが振られている必要があるため、save()を行った後に改めて紐づけを行い、save_m2m()する必要があります。

views.py
class BookCreateView(generic.CreateView):
    """書籍登録画面のビュー"""

    model = Book
    template_name = "create.html"
    form_class = BookCreateForm
    
    def form_valid(self, form: ModelForm) -> HttpResponse:
        book = form.save(commit=False)
	authors_list = form.cleaned_data.get("author")
	publisher = form.cleaned_data.get("publisher")
	
	# publisherをBookに紐づけ
        if publisher:
            book.publisher, created = Publisher.objects.get_or_create(name=publisher)
        
        book.save() # 一旦save
	
	# authorをBookに紐づけ
	# ManyToManyのFieldにaddするにはidが振られている必要があるため、
	# save()した後に登録する
        if authors_list:
            for author in authors_list:
                book.author.add(Author.objects.get_or_create(name=author)[0])
	    
        form.save_m2m()
        return super().form_valid(form)

まとめ

ForeignKeyやManyToManyといったリレーションを持つフィールドのformで任意の入力値を入れるには、form側でModelからの継承ではない新たなフィールドを定義、Viewでget_or_createを使用してオブジェクトを受け取り、Modelに登録します。今回はCharFieldでやってみましたが、別のフィールドでも応用できると思います。

脚注
  1. Modelから引き継ぐフィールドが1つだけならそもそもModelFormを使わなくてもよさそうですが、実際のアプリはもっと多くのフィールドがあるのでModelFormを利用しています ↩︎

Discussion