この記事は「Django Advent Calendar 2016 - Qiita」の11日目の記事です。
最近、DjangoでExcel風な入力画面を持つWebアプリを作る機会がありました。
何か良い方法がないかを調べたところ、jQueryへの依存がないJavaScriptライブラリHandsontable
を知りました*1。
handsontable/handsontable: Handsontable Community Edition - A JavaScript/HTML5 Spreadsheet Library for Developers
公式ドキュメントが充実している他、日本語のQiita記事も分かりやすく書かれていました。
- Handsontable 使い方メモ1(基本) - Qiita
- Handsontable 使い方メモ2(グリッドのオプション) - Qiita
- Handsontable 使い方メモ3(カラム・セルオプション) - Qiita
- Handsontable 使い方メモ4(メソッド) - Qiita
ただ、実際にやってみたところ色々と悩んだたため、メモを残しておきます。
目次
環境
- Windows10
- Windowsで動いているため、他の環境でも動作すると思います
- Python 3.5.2
- Django 1.10.4
- Handsontable 0.29.0
- js-cookie.js 2.1.3
- Google Chrome 55.0.2883.87 m (64-bit)
- Chromeなので、以下の機能を使用
- アロー関数 (
=>
) - JavaScriptのFetch API
- テンプレートリテラル (
`${variable_name}`
)
- アロー関数 (
- Chromeなので、以下の機能を使用
完成イメージはこんな感じです。
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化する方法について調べたところ、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がありました。
ただ、ソースコードを読んでみると、
- django.http.JsonResponseではjson.dumps()をしている(このあたり)。
- 拡張シリアライザの基底クラス(Serializer)でもjson_dumps()をしている(このあたり)。
とのことで、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関連のファイル
- Releases · handsontable/handsontableよりダウンロードし、以下に配置
- static/lib/handsontable.full.min.js.js
- static/lib/handsontable.css
- Releases · handsontable/handsontableよりダウンロードし、以下に配置
js_cookieファイル
- Releases · js-cookie/js-cookieよりダウンロードし、以下に配置
- static/lib/js.cookie.js
- Releases · js-cookie/js-cookieよりダウンロードし、以下に配置
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