Django
のプロジェクトを開発しているときに、「 Formset
」を使うことがよくあるんですが、毎回ネットとかで調べながら実装しているので、 Django
の Formset
の実装方法をメモとしてまとめておきます。
開発環境
今回使用した開発環境の Python
と Django
のバージョンは下記の通りです。
- 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/
にアクセスすると、下記の画面が表示されます。
質問と日付を入力して「登録」ボタンをクリックすると。こんな感じにデータが登録され明細が表示されます。
「追加」ボタンをクリックすると明細が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_data
と form_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
の実装方法を理解しておくことで、画面レイアウトの設計などもより効率化できたりすると思うので、しっかりと学習しておきたいですね。
コメント
こんにちは。
こちらの記事とても参考になりました。
有益な情報をありがとうございました。
こちらのサンプルでは一つの質問に対して、その選択肢と投票数を動的に追加していく挙動でしたが、例えば、質問を動的に追加、さらにその動的に追加した質問のそれぞれに選択肢と投票数を動的に追加などといった事をするにはどのようなコードを書いたら良いでしょうか?
お忙しい中お手数掛けて大変申し訳ございませんが、アドバイス頂けると嬉しいです。