Django Logo

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

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

開発環境

今回使用した開発環境の PythonDjango のバージョンは下記の通りです。

  • Python : 3.6.2
  • Django : 2.x

Django のプロジェクトを作成する

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

$ django-admin startproject mysite

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

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

$ python manage.py startapp polls

Django アプリケーションの下準備

今回は 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>

Django のアプリを実際に動かしてみる

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

$ python manage.py runserver

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

Django Formset 1

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

Django Formset 2

追加」ボタンをクリックすると明細が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 の実装方法を理解しておくことで、画面レイアウトの設計などもより効率化できたりすると思うので、しっかりと学習しておきたいですね。

「[Django]Formset を使った実装方法まとめ」への1件のフィードバック

  1. こんにちは。
    こちらの記事とても参考になりました。
    有益な情報をありがとうございました。

    こちらのサンプルでは一つの質問に対して、その選択肢と投票数を動的に追加していく挙動でしたが、例えば、質問を動的に追加、さらにその動的に追加した質問のそれぞれに選択肢と投票数を動的に追加などといった事をするにはどのようなコードを書いたら良いでしょうか?

    お忙しい中お手数掛けて大変申し訳ございませんが、アドバイス頂けると嬉しいです。

コメントする

メールアドレスが公開されることはありません。 が付いている欄は必須項目です