Django + Handsontable.jsを使って、Excel風な入力画面を作ってみた

この記事は「Django Advent Calendar 2016 - Qiita」の11日目の記事です。

 
最近、DjangoExcel風な入力画面を持つWebアプリを作る機会がありました。

何か良い方法がないかを調べたところ、jQueryへの依存がないJavaScriptライブラリHandsontableを知りました*1
handsontable/handsontable: Handsontable Community Edition - A JavaScript/HTML5 Spreadsheet Library for Developers

 
公式ドキュメントが充実している他、日本語のQiita記事も分かりやすく書かれていました。

 
ただ、実際にやってみたところ色々と悩んだたため、メモを残しておきます。

 
目次

 
 

環境

 
完成イメージはこんな感じです。

f:id:thinkAmi:20161210233021p:plain

 

Django側の実装

URLとModel

プロジェクトとアプリを作ります。

(env) >pip install django
(env) >django-admin startproject myproject .

(env) >python manage.py startapp myapp

 

myproject/urls.py

from django.conf.urls import url, include
from django.contrib import admin

urlpatterns = [
    url(r'^admin/', admin.site.urls),
    # addition for myapp
    url(r'^myapp/', include('myapp.urls', 'my')),
]

 

myapp/urls.py

urlpatterns = [
    # 一覧ページ
    url(r'^records/$',
        IndexListView.as_view(),
        name='record-index',
    ),
    # 新規作成ページ
    url(r'^records/new$', 
        TemplateView.as_view(template_name='myapp/detail.html'),
        name='record-new'
    ),
    # 編集ページ
    url(r'^records/(?P<pk>[0-9]+)/edit$', 
        TemplateView.as_view(template_name='myapp/detail.html'),
        name='record-edit'
    ),
    # Ajax用
    url(r'^ajax/records/(?P<pk>[0-9]+)$',
        HandsonTableView.as_view(),
        name='ajax',
    ),
]

 

myapp/models.py

from django.db import models

class Header(models.Model):
    update_at = models.DateTimeField('UpdateAt')

class Detail(models.Model):
    header = models.ForeignKey(Header)
    purchase_date = models.DateField('Date')
    name = models.CharField('Name', max_length=255)
    price = models.DecimalField('Price', max_digits=10, decimal_places=0)

 

View

一覧用・新規作成用・編集用のViewはurls.pyで設定したため、views.pyにはAjax用を実装します。

基底のViewは

  • HTMLテンプレートは不要
  • GETとPOSTへのレスポンスが返せればいい

を考慮し、django.views.Viewを使います*2

myapp/views.py

class HandsonTableView(View):
    def get(self, request, *args, **kwargs):
        ...
    def post(self, request, *args, **kwargs):
        ...

 

Ajax用ViewのGETを実装

get()メソッドには

  • ModelをJSON化する
  • JSONをレスポンスとして返す

を実装します。

 
ModelをJSON化する方法について調べたところ、django.core.serializersを使うのが良さそうでした。

ただ、そのままではHandsontableに不要な項目がJSONに含まれてしまいます。そのため、以下を参考に拡張シリアライザを作ります。
Django標準のjson serializerをカスタマイズする - 勉強不足

myproject/serializers.py

class Serializer(Serializer):
    def get_dump_object(self, obj):
        return self._current

    def start_serialization(self):
        super(Serializer, self).start_serialization()
        # 日本語対応
        self.json_kwargs["ensure_ascii"] = False
        # タブインデントは2にしておく
        self.json_kwargs['indent'] = 2

 
合わせて、拡張シリアライザの設定をsettings.pyに追加します。

myproject/settings.py

SERIALIZATION_MODULES = {
    "handsontablejson": "myproject.serializers"
}

 
続いて、JSONをレスポンスとして返す方法について調べたところ、django.http.JsonResponseがありました。

ただ、ソースコードを読んでみると、

とのことで、JsonResponseと拡張シリアライザを使うとjson_dumps()が2回呼ばれてしまいます。

そのため、今回はHttpResponseと拡張シリアライザを使います。

myapp/views.py

def get(self, request, *args, **kwargs):
    details = Detail.objects.filter(header__pk=self.kwargs.get('pk')).select_related().all()

    return HttpResponse(
        serializers.serialize('handsontablejson', details),
        content_type='application/json'
    )

 

Ajax用ViewのPOSTを実装

myapp/views.py

def post(self, request, *args, **kwargs):
    body_unicode = request.body.decode('utf-8')
    body = json.loads(body_unicode)

    header = self.update_header(self.kwargs.get('pk'))

    # 明細部分は、DELETE&INSERTで作る
    Detail.objects.filter(header=header).delete()

    for b in body:
        Detail(
            header=header,
            purchase_date=b.get('purchase_date'),
            name = b.get('name'),
            price = b.get('price'),
        ).save()

    return HttpResponse('OK')


def update_header(self, pk):
    if int(pk) == NEW_PAGE_ID:
        new_header = Header(update_at = timezone.now())
        new_header.save()
        return new_header

    header = Header.objects.filter(pk=pk).first()
    header.update_at = timezone.now()
    header.save()
    return header

 

Viewのテンプレートを用意

myapp/templates/myapp/header_list.html

<!DOCTYPE html>
<html lang="ja" xmlns="http://www.w3.org/1999/xhtml">
<head>
    <meta charset="utf-8" />
    <title>My Site</title>
</head>
<body>
    <div id="main">
        <h1>一覧</h1>
        <a href="{% url 'my:record-new' %}">新規登録</a>
        <table>
            <tr>
                <th>更新日時</th>
                <th>操作</th>
            </tr>
            {% for record in object_list %}
                <tr>
                    <td>{{ record.update_at }}</td>
                    <td><a href="{% url 'my:record-edit' record.id %}">編集</a></td>
                </tr>
            {% endfor %}
        </table>
    </div>
</body>
</html>

 

myapp/templates/myapp/edit.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>Handsontable</title>
    {% load static %}
    <link rel="stylesheet" href="{% static "libs/handsontable.css" %}" />
  </head>
  <body>
    <!--id=gridにHandsontableの値が設定される-->
    <div id="grid">
    </div>

    <button type="button" id="add">行の追加</button>
    <button type="button" id="save">保存</button>
    <a href="{% url 'my:record-index' %}">一覧へ戻る</a>

    <script src="{% static "libs/handsontable.full.min.js" %}"></script>
    <script src="{% static "libs/js.cookie.js" %}"></script>
    <script src="{% static "js/myscript.js" %}"></script>
  </body>
</html>

 

静的ファイルの設定

collectstaticを使う場合に備えて、settings.pyにSTATIC_ROOTを設定します。

myproject/settings.py

STATIC_ROOT = os.path.join(BASE_DIR, 'static')

 
以上でDjango側の実装は完了です。

 

JavaScript側の実装

ライブラリの配置

 

Handsontableオブジェクトの設定

データバインディングする時はdataSchemaの設定が必要です。

ただ、今回はcolumnsでtype指定も同時に行ったせいか、dataSchemaの設定をしなくても動作しました。

myapp/static/js/myscript.js

var data = [];

var grid = document.getElementById('grid');

var table = new Handsontable(grid, {
    data: data,

    columns: [
        { data: 'purchase_date', type: 'text' },
        { data: 'name', type: 'text' },
        { data: 'price', type: 'numeric' },
    ],
    
    // 列ヘッダを表示
    colHeaders: ["日付", "名前", "価格"],
    // 行ヘッダを表示(1からの連番)
    rowHeaders: true,
    // 列幅
    colWidths: [120, 200, 100]
});

 

イベントリスナーの追加

今回、

  • loadした時に対象データを読み込む
  • ボタンを押したときにHandsontableのhookを発火する

というイベントリスナーを実装します。

 
myapp/static/js/myscript.js

const NEW_PAGE_ID = 0;
var id = (() => {
    var found = location.pathname.match(/\/myapp\/records\/(.*?)\/edit$/);
    // 新規作成の時は、便宜上id=0とみなして処理する
    return found ? found[1] : NEW_PAGE_ID;
})();

document.addEventListener("DOMContentLoaded", () => {
    // loadした時に、Handsontableの初期値を取得・表示
    fetch(`/myapp/ajax/records/${id}`, {
        method: 'GET',
    }).then(response => {
        console.log(response.url, response.type, response.status);

        response.json().then(json => {
            for (var i = 0; i < json.length; i++){
                data.push({
                    purchase_date: json[i].purchase_date,
                    name: json[i].name,
                    price: json[i].price,
                });
            }

            table.render();
        });
    }).catch(err => console.error(err));
}, false);

document.getElementById('save').addEventListener('click', () => {
    // 保存ボタンを押したときに発火するHandsontableのhook
    Handsontable.hooks.run(table, 'onSave', data);
});

document.getElementById('add').addEventListener('click', () => {
    // 行追加を押したときに発火するHandsontableのhook
    Handsontable.hooks.run(table, 'onAddRow', data);
});

 

AjaxでPOSTする時のCSRF対策をパスする方法

Djangoのデフォルトではdjango.middleware.csrf.CsrfViewMiddlewareが組み込んであるため、POST時はCSRF対策をパスする必要があります。

そのため、公式ドキュメントを参考に、

  • Cookieからキーがcsrftokenの値を取得
  • POST時にX-CSRFTokenとしてHTTPリクエストヘッダに追加

を実装します。

myapp/static/js/myscript.js

Handsontable.hooks.add('onAddRow', mydata => {
    // 行の追加
    table.alter('insert_row', data.length);
});

Handsontable.hooks.add('onSave', mydata => {
    // 保存時の処理
    // CSRF対策のCookieを取得する
    // https://docs.djangoproject.com/en/1.10/ref/csrf/#ajax
    var csrftoken = Cookies.get('csrftoken');
    
    // Djangoのdjango.middleware.csrf.CsrfViewMiddlewareを使っているため、
    // POST時にmodeとcredentialsとX-CSRFTokenヘッダを付ける
    fetch(`/myapp/ajax/records/${id}`, {
        method: 'POST',
        headers: {
            'content-type': 'application/json',
            'X-CSRFToken': csrftoken
        },
        mode: 'same-origin',
        credentials: 'same-origin',
        body: JSON.stringify(mydata),
    }).then(response => {
        console.log(response.url, response.type, response.status);

        if (response.status == '200'){
            window.alert('保存しました');
            // 一覧にリダイレクト
            location.href = '/myapp/records';
        }
        else{
            window.alert('保存できませんでした');
        }
    }).catch(err => console.error(err));
});

 
以上でJavaScript側の実装も完了です。

 

確認

(env) >python manage.py makemigrations
(env) >python manage.py migrate
(env) >python manage.py runserver

で動作を確認します。

 

ソースコード

GitHubに上げました。
thinkAmi-sandbox/Django_Handsontable-sample

 
なお、

  • handsontable.js
  • js_cookie.js

のライブラリは上記GitHubに含んでいませんので、試す際にはダウンロードしてmyapp/static/libの下に置いてください。

*1:有償のPro版と無償のFree版があります

*2:Django1.10より、django.views.generic.Viewの他にdjango.views.Viewをimportできるようになったようです:https://docs.djangoproject.com/en/1.10/ref/class-based-views/base/#django.views.generic.base.View