[Django]Formsetを使った実装方法まとめ

Djangoで開発しているときに、「Formset」を使うことがよくあるんですが、毎回ネットとかで調べながら実装しているので、メモとして実装方法をまとめておきます。

開発環境

  • Python:3.6.2
  • Django:2.x

プロジェクトを作成する

下記のコマンドを実行してプロジェクトを作成します。

$ django-admin startproject mysite

アプリケーションを作成する

Djangoのチュートリアルで作成されている「polls」アプリケーションを作成します。

$ python manage.py startapp polls

下準備

今回はFormsetの使い方の説明なので、モデルの実装やビューの実装などの説明はスキップします。コードは下記に書いておくので、参考にしてもらえればと思います。

ここに書いてないコードは基本的にはそのままで問題ないと思います。

もし、コード確認しながら書くのが面倒であれば下記のURLよりコードをクローンして使ってください。

mysite/urls.py

from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('polls/', include(('polls.urls', 'polls'), namespace='polls')),
    path('admin/', admin.site.urls),
]

polls/app.py

from django.apps import AppConfig


class PollsConfig(AppConfig):
    name = 'polls'

polls/urls.py

from django.urls import path

from . import views

urlpatterns = [
    path('create/', views.QuestionCreateView.as_view(), name='create'),
    path('<int:pk>/update/', views.QuestionUpdateView.as_view(), name='update'),
]

polls/models.py

from django.db import models
from django.urls import reverse


class Question(models.Model):
    question_text = models.CharField(max_length=200, verbose_name='質問')
    pub_date = models.DateField(verbose_name='日付')

    def get_absolute_url(self):
        return reverse('polls:update', kwargs={'pk': self.pk})


class Choice(models.Model):
    question = models.ForeignKey(Question, on_delete=models.CASCADE)
    choice_text = models.CharField(max_length=200, verbose_name='選択肢')
    votes = models.IntegerField(default=0, verbose_name='投票数')

polls/forms.py

from django import forms

from .models import Question, Choice


class QuestionForm(forms.ModelForm):
    class Meta:
        model = Question
        fields = ['question_text', 'pub_date']


class ChoiceForm(forms.ModelForm):
    class Meta:
        model = Choice
        fields = ['choice_text', 'votes']


ChoiceFormset = forms.inlineformset_factory(Question, Choice, ChoiceForm, extra=0)

polls/views.py

from django.shortcuts import redirect
from django.views import generic
from .forms import QuestionForm, ChoiceFormset
from .models import Question


class QuestionCreateView(generic.CreateView):
    form_class = QuestionForm
    template_name = 'question/create.html'


class QuestionUpdateView(generic.UpdateView):
    model = Question
    form_class = QuestionForm
    template_name = 'question/update.html'

    def get_context_data(self, **kwargs):
        ctx = super(QuestionUpdateView, self).get_context_data(**kwargs)

        ctx.update(dict(formset=ChoiceFormset(self.request.POST or None, instance=self.object)))

        return ctx

    def form_valid(self, form):
        ctx = self.get_context_data()

        formset = ctx['formset']

        if formset.is_valid():
            self.object = form.save(commit=False)
            self.object.save()

            formset.save()

            return redirect(self.get_success_url())
        else:
            ctx['form'] = form
            return self.render_to_response(ctx)

templates/question/create.html

{% load staticfiles %}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <!-- Bootstrap CSS -->
    <link rel="stylesheet" type="text/css" href="{% static 'css/bootstrap.min.css' %}">

    <!-- Font Awesome CSS -->
    <link rel="stylesheet" type="text/css" href="{% static 'font-awesome/css/font-awesome.min.css' %}">

    <!-- Plugin CSS -->
    <link rel="stylesheet" type="text/css" href="{% static 'plugins/datetimepicker/css/daterangepicker.css' %}">

    <!-- Custom CSS -->
    <link rel="stylesheet" type="text/css" href="{% static 'css/style.css' %}">

    <!-- BEGIN CSS for this page -->
    <link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/1.10.16/css/dataTables.bootstrap4.min.css"/>
    <!-- END CSS for this page -->

    <style>
        /* FORM ROWS */
        .form-row {
            overflow: hidden;
            padding: 10px;
            font-size: 13px;
            border-bottom: 1px solid #eee;
            display: table-row;
            margin: 0;
        }
        .form-row img, .form-row input {
            vertical-align: middle;
        }
        .form-row label input[type="checkbox"] {
            margin-top: 0;
            vertical-align: 0;
        }
        form .form-row p {
            padding-left: 0;
        }
        .empty-form {
            display: none;
        }
    </style>

    <!-- Django jquery -->
    <script src="{% static 'admin/js/vendor/jquery/jquery.js' %}"></script>
    <!-- Django jquery init -->
    <script src="{% static 'admin/js/jquery.init.js' %}"></script>
    <!-- Django RelatedObjectLookups -->
    <script src="{% static 'admin/js/admin/RelatedObjectLookups.js' %}"></script>

    <script src="{% static 'js/modernizr.min.js' %}"></script>
    <script src="{% static 'js/jquery.min.js' %}"></script>
    <script src="{% static 'js/moment.min.js' %}"></script>

    <script src="{% static 'js/popper.min.js' %}"></script>
    <script src="{% static 'js/bootstrap.min.js' %}"></script>

    <script src="{% static 'js/detect.js' %}"></script>
    <script src="{% static 'js/fastclick.js' %}"></script>
    <script src="{% static 'js/jquery.blockUI.js' %}"></script>
    <script src="{% static 'js/jquery.nicescroll.js' %}"></script>

    <!-- Plugin js -->
    <script src="{% static 'plugins/datetimepicker/js/moment.min.js' %}"></script>
    <script src="//cdnjs.cloudflare.com/ajax/libs/moment.js/2.11.2/locale/ja.js"></script>
    <script src="{% static 'plugins/datetimepicker/js/daterangepicker.js' %}"></script>

    <!-- prepopulate.js -->
    <script src="{% static 'js/prepopulate.js' %}"></script>
    <!-- inlines.js -->
    <script src="{% static 'js/inlines.js' %}"></script>
</head>
<body>
<div class="row">
    <div class="col-xs-12 col-sm-12 col-md-12 col-lg-12 col-xl-12">
        <form action="" method="post">
            {% csrf_token %}
            <div class="card">
                <div class="card-header">
                    <h3>登録</h3>
                </div><!-- /.card-header -->
                <div class="card-body">
                    <div class="row">
                        {% for field in form %}
                            <div class="col-md-2 col-sm-6 col-xs-12">
                                <div class="form-group">
                                    <label for="{{ field.id_for_label }}">{{ field.label }}</label>
                                    {{ field }}
                                    {{ field.errors }}
                                </div>
                            </div><!-- /.col -->
                        {% endfor %}
                    </div><!-- /.row -->
                </div><!-- /.card-body -->
                <div class="card-footer">
                    <button type="submit" name="create" class="btn btn-primary"><span class="btn-label"><i class="fa fa-fw fa-pencil"></i></span>登録</button>
                </div><!-- /.card-body -->
            </div><!-- ./card -->
        </form>
    </div><!-- /.col -->
</div><!-- /.row -->

</body>
</html>

templates/question/update.html

{% load staticfiles %}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <!-- Bootstrap CSS -->
    <link rel="stylesheet" type="text/css" href="{% static 'css/bootstrap.min.css' %}">

    <!-- Font Awesome CSS -->
    <link rel="stylesheet" type="text/css" href="{% static 'font-awesome/css/font-awesome.min.css' %}">

    <!-- Plugin CSS -->
    <link rel="stylesheet" type="text/css" href="{% static 'plugins/datetimepicker/css/daterangepicker.css' %}">

    <!-- Custom CSS -->
    <link rel="stylesheet" type="text/css" href="{% static 'css/style.css' %}">

    <!-- BEGIN CSS for this page -->
    <link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/1.10.16/css/dataTables.bootstrap4.min.css"/>
    <!-- END CSS for this page -->

    <style>
        /* FORM ROWS */
        .form-row {
            overflow: hidden;
            padding: 10px;
            font-size: 13px;
            border-bottom: 1px solid #eee;
            display: table-row;
            margin: 0;
        }
        .form-row img, .form-row input {
            vertical-align: middle;
        }
        .form-row label input[type="checkbox"] {
            margin-top: 0;
            vertical-align: 0;
        }
        form .form-row p {
            padding-left: 0;
        }
        .empty-form {
            display: none;
        }
    </style>

    <!-- Django jquery -->
    <script src="{% static 'admin/js/vendor/jquery/jquery.js' %}"></script>
    <!-- Django jquery init -->
    <script src="{% static 'admin/js/jquery.init.js' %}"></script>
    <!-- Django RelatedObjectLookups -->
    <script src="{% static 'admin/js/admin/RelatedObjectLookups.js' %}"></script>

    <script src="{% static 'js/modernizr.min.js' %}"></script>
    <script src="{% static 'js/jquery.min.js' %}"></script>
    <script src="{% static 'js/moment.min.js' %}"></script>

    <script src="{% static 'js/popper.min.js' %}"></script>
    <script src="{% static 'js/bootstrap.min.js' %}"></script>

    <script src="{% static 'js/detect.js' %}"></script>
    <script src="{% static 'js/fastclick.js' %}"></script>
    <script src="{% static 'js/jquery.blockUI.js' %}"></script>
    <script src="{% static 'js/jquery.nicescroll.js' %}"></script>

    <!-- Plugin js -->
    <script src="{% static 'plugins/datetimepicker/js/moment.min.js' %}"></script>
    <script src="//cdnjs.cloudflare.com/ajax/libs/moment.js/2.11.2/locale/ja.js"></script>
    <script src="{% static 'plugins/datetimepicker/js/daterangepicker.js' %}"></script>

    <!-- prepopulate.js -->
    <script src="{% static 'js/prepopulate.js' %}"></script>
    <!-- inlines.js -->
    <script src="{% static 'js/inlines.js' %}"></script>
</head>
<body>
<div class="row">
    <div class="col-xs-12 col-sm-12 col-md-12 col-lg-12 col-xl-12">
        <form action="" method="post">
            {% csrf_token %}
            <div class="card">
                <div class="card-header">
                    <h3>登録</h3>
                </div><!-- /.card-header -->
                <div class="card-body">
                    <div class="row">
                        {% for field in form %}
                            <div class="col-md-2 col-sm-6 col-xs-12">
                                <div class="form-group">
                                    <label for="{{ field.id_for_label }}">{{ field.label }}</label>
                                    {{ field }}
                                    {{ field.errors }}
                                </div>
                            </div><!-- /.col -->
                        {% endfor %}
                    </div><!-- /.row -->
                    <div class="row">
                        <div class="col-xs-12 col-sm-12 col-md-12 col-lg-12 col-xl-12">
                            <div class="js-inline-admin-formset inline-group" id="{{ formset.prefix }}-group" data-inline-type="tabular"
                                 data-inline-formset='{"options": {"deleteText": "削除", "prefix": "{{ formset.prefix }}","addText": "追加"}, "name": "#{{ formset.prefix }}"}'>
                                <div class="tabular inline-related">
                                    {{ formset.management_form }}
                                    <table class="table table-bordered">
                                        <thead>
                                        <tr>
                                            {% for field in formset.empty_form.visible_fields %}
                                                <th nowrap{% if field.field.required %} class="required"{% endif %}>{{ field.label|capfirst }}</th>
                                            {% endfor %}
                                        </tr>
                                        </thead>
                                        <tbody>
                                        {% for field in formset %}
                                            <tr class="form-row" id="{{ formset.prefix }}-{{ forloop.counter0 }}">
                                                {% for f in field.visible_fields %}
                                                    <td nowrap class="field-{{ f.name }}">
                                                        {% if forloop.first %}
                                                            {% for hidden_field in field.hidden_fields %}
                                                                {{ hidden_field }}
                                                            {% endfor %}
                                                        {% endif %}
                                                        {% if f.is_readonly %}
                                                            <p>{{ f.contents }}</p>
                                                        {% else %}
                                                            {{ f }}
                                                            {{ f.errors }}
                                                            </td>
                                                        {% endif %}
                                                {% endfor %}
                                            </tr>
                                        {% endfor %}
                                        <tr class="form-row empty-form" id="{{ formset.prefix }}-empty">
                                            {% for field in formset.empty_form.visible_fields %}
                                                <td nowrap class="field-{{ field.name }}">
                                                    {{ field }}
                                                </td>
                                            {% endfor %}
                                        </tr>
                                        </tbody>
                                    </table>
                                </div><!-- /.tabular -->
                            </div><!-- /.js-inline-admin-formset -->
                        </div><!-- /.col -->
                    </div><!-- /.row -->
                </div><!-- /.card-body -->
                <div class="card-footer">
                    <button type="submit" name="create" class="btn btn-primary"><span class="btn-label"><i class="fa fa-fw fa-pencil"></i></span>登録</button>
                </div><!-- /.card-body -->
            </div><!-- ./card -->
        </form>
    </div><!-- /.col -->
</div><!-- /.row -->

</body>
</html>

実際に動かしてみる

runserverコマンドを実行して実際にこのアプリケーションを起動してみます。

$ python manage.py runserver

http://localhost:8000/polls/create/にアクセスすると、下記の画面が表示されます。

質問と日付を入力して「登録」ボタンをクリックすると。こんな感じにデータが登録され明細が表示されます。

「追加」ボタンをクリックすると明細が1行追加され、明細データの登録ができます。明細を削除する場合は「削除列」のチェックボックスにチェックを入れて明細行の削除を行います。

Formset実装時のポイント

Formset実装時のポイントは以下の3つのファイルです。

Formsetの定義

Formsetとして定義するモデルのFormを作成し、Formsetを定義します。

forms.py

class ChoiceForm(forms.ModelForm):
    class Meta:
        model = Choice
        fields = ['choice_text', 'votes']


ChoiceFormset = forms.inlineformset_factory(Question, Choice, ChoiceForm, extra=0)

ビューの実装

通常、generic.UpdateViewではFormsetをそのまま組み込むことはできないので、get_context_dataform_validをオーバーライドします。

views.py

class QuestionUpdateView(generic.UpdateView):
    model = Question
    form_class = QuestionForm
    template_name = 'question/update.html'

    def get_context_data(self, **kwargs):
        ctx = super(QuestionUpdateView, self).get_context_data(**kwargs)

        ctx.update(dict(formset=ChoiceFormset(self.request.POST or None, instance=self.object)))

        return ctx

    def form_valid(self, form):
        ctx = self.get_context_data()

        formset = ctx['formset']

        if formset.is_valid():
            self.object = form.save(commit=False)
            self.object.save()

            formset.save()

            return redirect(self.get_success_url())
        else:
            ctx['form'] = form
            return self.render_to_response(ctx)

HTMLの実装

画面のHTMLは下記のように実装します。

update.html

                    <div class="row">
                        <div class="col-xs-12 col-sm-12 col-md-12 col-lg-12 col-xl-12">
                            <div class="js-inline-admin-formset inline-group" id="{{ formset.prefix }}-group" data-inline-type="tabular"
                                 data-inline-formset='{"options": {"deleteText": "削除", "prefix": "{{ formset.prefix }}","addText": "追加"}, "name": "#{{ formset.prefix }}"}'>
                                <div class="tabular inline-related">
                                    {{ formset.management_form }}
                                    <table class="table table-bordered">
                                        <thead>
                                        <tr>
                                            {% for field in formset.empty_form.visible_fields %}
                                                <th nowrap{% if field.field.required %} class="required"{% endif %}>{{ field.label|capfirst }}</th>
                                            {% endfor %}
                                        </tr>
                                        </thead>
                                        <tbody>
                                        {% for field in formset %}
                                            <tr class="form-row" id="{{ formset.prefix }}-{{ forloop.counter0 }}">
                                                {% for f in field.visible_fields %}
                                                    <td nowrap class="field-{{ f.name }}">
                                                        {% if forloop.first %}
                                                            {% for hidden_field in field.hidden_fields %}
                                                                {{ hidden_field }}
                                                            {% endfor %}
                                                        {% endif %}
                                                        {% if f.is_readonly %}
                                                            <p>{{ f.contents }}</p>
                                                        {% else %}
                                                            {{ f }}
                                                            {{ f.errors }}
                                                            </td>
                                                        {% endif %}
                                                {% endfor %}
                                            </tr>
                                        {% endfor %}
                                        <tr class="form-row empty-form" id="{{ formset.prefix }}-empty">
                                            {% for field in formset.empty_form.visible_fields %}
                                                <td nowrap class="field-{{ field.name }}">
                                                    {{ field }}
                                                </td>
                                            {% endfor %}
                                        </tr>
                                        </tbody>
                                    </table>
                                </div><!-- /.tabular -->
                            </div><!-- /.js-inline-admin-formset -->
                        </div><!-- /.col -->
                    </div><!-- /.row -->

最後に

Formsetの実装については、業務系のシステムなどで「伝票」「明細」などを扱う場合は、よく使うことになるものだと思います。Formsetの実装方法を理解しておくことで、画面レイアウトの設計などもより効率化できたりすると思うので、しっかりと学習しておきたいですね。