Django、インラインフォームセットを使う

Python - Django
2018年10月18日11:46に更新(約27日前)
2018年10月18日5:36に作成(約27日前)

旧ブログ移行記事です。

概要

Django、モデルフォームセットを使うでモデルフォームセットについて説明しましたが、今回はそれの兄弟にあたるインラインフォームセットです。 モデルフォームセットと似ていますが、少し用途が違います。

前回は記事を一度に複数作成しましたが、一つの記事には任意の数の添付ファイルをつけれるようにしてみました。

from django.db import models
from django.utils import timezone


class Post(models.Model):
    title = models.CharField('タイトル', max_length=200)
    text = models.TextField('本文')
    date = models.DateTimeField('日付', default=timezone.now)

    def __str__(self):
        return self.title


class File(models.Model):
    name = models.CharField('ファイル名', max_length=255)
    src = models.FileField('添付ファイル')
    target = models.ForeignKey(
        Post, verbose_name='紐づく記事',
        blank=True, null=True,
        on_delete=models.SET_NULL
    )

通常であれば記事を作成し保存したあとに、ファイルを作成して記事に紐づけていきますが、インラインフォームセットを使うと記事を作成すると同時にファイルも作成できます。

自分で作ったページで表示させる

見た目

記事の作成フォームのあとに、ファイル添付のフォームがいくつか表示されます。 ファイル添付欄が表示された

forms.py

forms.inlineformset_factory関数を使います。

FileFormset = forms.inlineformset_factory(
    Post, File, fields='__all__',
    extra=5, max_num=5, can_delete=False
)

第一引数と第二引数は必須で、親となるモデルと自身を指定します。 モデルフォームセットと同様に、ファイル部分のフォームをカスタマイズした場合はform_class引数にそれを指定し、自動生成されるフォームでいいならばfieldsexclude引数を指定します。 extraは新規作成用フォームの数で、デフォルトは3です。 記事に添付できるフォームの最大件数を指定したいならばmax_num引数を指定します。 can_deleteは削除用チェックボックスが表示されます。デフォルトTrueです。

views.py

モデルフォームセットもそうですが、インラインフォームセットは特に複雑になりがちです。今回の例は2種類のフォームを扱います。はじめのうちは関数ビューで書いたほうがわかりやすいです。

def add_post(request):
    form = PostCreateForm(request.POST or None)
    context = {'form': form}
    if request.method == 'POST' and form.is_valid():
        post = form.save(commit=False)
        formset = FileFormset(request.POST, files=request.FILES, instance=post)  # 今回はファイルなのでrequest.FILESが必要
        if formset.is_valid():
            post.save()
            formset.save()
            return redirect('app:index')

        # エラーメッセージつきのformsetをテンプレートへ渡すため、contextに格納
        else:
            context['formset'] = formset

    # GETのとき
    else:
        # 空のformsetをテンプレートへ渡す
        context['formset'] = FileFormset()

    return render(request, 'app/post_form.html', context)

インラインフォームでは、どのデータに紐づくかという指定をする必要があります。それがinstance引数です。 commit=Falseな物でもいいので、この段階でモデルインスタンスを取得しておく必要があります。

formset = FileFormset(request.POST, files=request.FILES, instance=post)

今回は記事も作っていますが、他によくあるのは既存のデータをinstance引数に指定するケースです。特にrequest.userを指定するケースはよくあります。これらの場合は扱うフォームが1種類(formsetのみ)で済むので、処理がだいぶ見やすくなります。

formset = FileFormset(request.POST, files=request.FILES, instance=request.user)
formset = FileFormset(request.POST, files=request.FILES, instance=Post.objects.get(pk=hoge))

base.html

Bootstrap4のスターターテンプレートです。

<!doctype html>
<html lang="ja">
  <head>
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

    <!-- Bootstrap CSS -->
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">

    <title>フォームセット</title>
  </head>
  <body>
    <div class="container mt-3">
        {% block content %}{% endblock %}
    </div>

    <!-- Optional JavaScript -->
    <!-- jQuery first, then Popper.js, then Bootstrap JS -->
    <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js" integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49" crossorigin="anonymous"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js" integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy" crossorigin="anonymous"></script>
  </body>
</html>

post_form.html

モデルフォームセットを扱う場合と同じで、それに通常の{{ form }}が増えた感じです。forで取り出したり各フィールドを手動で取り出す場合は、Django、モデルフォームセットを使うと同様です。

<!-- ファイルをあつかうので、enctype="multipart/form-data" が必要 -->
<form action="" method="post" enctype="multipart/form-data">
    <h2>記事</h2>
    {{ form.as_p }}

    <h2>添付ファイル</h2>
    {{ formset.management_form }}
    {% for file_form in formset %}
        {{ file_form.as_p }}
        <hr>
    {% endfor %}

    {% csrf_token %}
    <button type="submit" class="btn btn-primary">送信</button>
</form>

更新処理でのインラインフォームセット

これは非常に簡単で、前述したビューよりもシンプルに作れます。

def update_post(request, pk):
    post = get_object_or_404(Post, pk=pk)
    form = PostCreateForm(request.POST or None, instance=post)
    formset = FileFormset(request.POST or None, files=request.FILES or None, instance=post)
    if request.method == 'POST' and form.is_valid() and formset.is_valid():
        form.save()
        formset.save()
        # 編集ページを再度表示
        return redirect('app:update_post', pk=pk)

    context = {
        'form': form,
        'formset': formset
    }

    return render(request, 'app/post_form.html', context)

記事と、すでに紐づけたファイルの更新用フォームが表示されていますね。 更新用インラインフォーム

admin管理サイトでインライン表示させる

人によっては、管理画面でもこのインラインフォームセットを使いたいと思うかもしれません。 これは非常に簡単に作ることができます!

admin.py

from django.contrib import admin
from .models import *


class FileInline(admin.StackedInline):
    model = File
    extra = 3


class PostAdmin(admin.ModelAdmin):
    inlines = [FileInline]


admin.site.register(Post, PostAdmin)
admin.site.register(File)  # 一応、ファイル単体の管理画面も作っておく

こんな感じになります。

admin.StackedInlineadmin.TabularInlineに変えると...

class FileInline(admin.TabularInline):
    model = File
    extra = 3

各フィールドが横一列に並びます。こちらも見た目にいいですね。

記事にコメントする

名無し
2018年11月7日22:07にコメント(約6日前)

質問がございます。 インラインフォームセットを使用して入力画面を作成したのですが、例えばextra=3を指定してみると、特に値を変更していないextraの分もmodelに設定したデフォルトの値でfomset.save()でデータベースに保存されてしまいます。これは正しい挙動でしょうか。もし、デフォルト値から修正があったもののみ保存するなど、制御することは可能でしょうか。。

お忙しいところ恐れ入ります。宜しくお願いいたします。

コメントに返信する

なりと
2018年11月8日15:15に返信(約5日前)

基本的に、何も変更していないフォームは保存されません。デフォルト値を指定していても、デフォルト値から変更したり、他のフィールドに値を入れない限りは変更されません。

ソースコードの内容を教えていただけますか。views.pyforms.pymodels.py、後は該当のテンプレートの内容がわかると助かります。

名無し
2018年11月8日23:41に返信(約5日前)

お世話になります。ご返信ありがとうございます。コードが少し多かったので、メールで送付させていただきました。 恐れ入りますが、お手すきの際にご確認くだされば幸いです。

宜しくお願いいたします。

名無し
2018年11月8日23:45に返信(約5日前)

追記です。

templateはbeforecompletioninfo_form.htmlです。 modelはBeforeCompletionInfoが親モデルになっていて、BeforeCompletionLoanCalcBeforeConpletionInterestCalcが子モデルになっており、BeforeCompletionLoanCalcFormsetBeforeCompletionInterestCalcFormsetinlineformset_factoryでFormSetを作成しています。

宜しくお願いいたします。

なりと
2018年11月9日13:50に返信(約4日前)

以下のように、prefix引数をつけるとどうなりますか。

        loan_formset = BeforeCompletionLoanCalcFormset(request.POST, instance=beforecompletioninfo, prefix='loan')
        interest_formset = BeforeCompletionInterestCalcFormset(request.POST, instance=beforecompletioninfo, prefix='interest')
        ...
        ...
        context['loan_formset'] = BeforeCompletionLoanCalcFormset(prefix='loan')
        context['interest_formset'] = BeforeCompletionInterestCalcFormset(prefix='interest')
名無し
2018年11月10日1:14に返信(約4日前)

ありがとうございます。 いただいた方法を試してみていますが、 今のところ変化はなく、追加や修正をしていなくても保存されてしまいます。

Button を押下するとadd_loan_calcが呼ばれて各フォームセットを保存し、そのままupdate_loan_calcへredirectするようになっているのですが、 例えばloan_formsetのextra=1にして、特に修正やデータの入力せずにButtonを押下すると、redirect先では前回追加したことになっているデータと、extra=1の分と思われる追加ぶんの2つの欄が表示されています。

Djangoの管理画面から見ても、特にいじっていないデータが確かに保存されてしまっています。

名無し
2018年11月10日9:49に返信(約4日前)

画像の「借入情報」と「利息情報」がフォームセットになっています。 この上にボタンがあり、それを押下するとadd_loan_calcが呼び出される想定をしています。

教えていただいたprefixを引数に指定し、試しにextra=2を設定して、これらのフォームセットの何も変更しないでボタンを押し出して、add_loan_calcの中で

for loan_fm in loan_formset:
    print('loan_fm.has_changed())

for int_fm in interest_formset:
    print('int_fm.has_changed())

してみたところ、それぞれのformset2ずつ全てTrueになったのですが、これはいかがなものでしょうか。

なりと
2018年11月10日14:52に返信(約3日前)

わかりました。モデルを以下のようにして変更してください。

def get_now_date():
    return timezone.now().date
...
...
start_calc_at = models.DateField('計算開始日', default=get_now_date)

DateFieldはあくまで年月日で、時間は持たないフィールドです。

しかしtimezone.nowは時間も含めた現在の日付を取得します。これにより、初期値...default=timezone.nowと送信データ(DateFieldで作られた入力欄、こちらは年月日のみ)が違うため、Django側では変更されたと認識され、新規作成されていました。

get_now_dateですが、これは現在の日付だけを返す関数です。timezone.now()datetime型として取得でき、それのdate属性にアクセスするとdate型のデータが取得できます。

まとめると、DateTimeFieldを使うときでdefault値を指定したい場合はtimezone.nowを、DateFieldでdetault値を指定したいときは今回のようにtimezone.now().dateのようなdate型のオブジェクトを使うようにすると間違いが起こらないでしょう。

名無し
2018年11月10日22:33に返信(約3日前)

できました…。 原因もよくわかり、対策できました。 ありがとうございました。 大変助かりました…。

まだまだ分かっていないことがたくさんあるなーと思いつつ、また少しずつ勉強させていただきます。

いつもありがとうございます。 感謝感謝でございます。