갈루아의 반서재


Django와 Handsontable.js를 이용한 엑셀풍의 입력화면 만들기



Django로 엑셀 풍의 입력 화면을 만들어보자. 이를 위해서 handsontable 을 이용한다. Handsontable은 엑셀의 외관을 가진 JavaScript/HTML5 스프레드시트 컴포넌트이다. 본 포스팅은 Hatena 블로거 thinkAmi님의 Django + Handsontable.jsを使って、Excel風な入力画面を作ってみた (http://thinkami.hatenablog.com/entry/2016/12/11/000415)을 참고로 작성되었다.



    

https://github.com/handsontable/handsontable





1. 환경



본 포스팅의 환경은 다음과 같다.

Ubuntu 14.04 + Python 3.5.2 + Django 1.9.8 + Handsontable 0.31.1 + js-cookie.js 2.1.3

목표는 아래와 같은 웹페이지를 만드는 것이다.




2. Handsontable installation


1) Handsontable 을 설치한다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# npm install handsontable --save
npm http GET https://registry.npmjs.org/handsontable
npm http 200 https://registry.npmjs.org/handsontable
npm http GET https://registry.npmjs.org/handsontable/-/handsontable-0.31.1.tgz
npm http 200 https://registry.npmjs.org/handsontable/-/handsontable-0.31.1.tgz
npm http GET https://registry.npmjs.org/pikaday
npm http GET https://registry.npmjs.org/zeroclipboard
npm http GET https://registry.npmjs.org/moment
npm http GET https://registry.npmjs.org/numbro
npm http 200 https://registry.npmjs.org/pikaday
npm http GET https://registry.npmjs.org/pikaday/-/pikaday-1.5.1.tgz
npm http 200 https://registry.npmjs.org/zeroclipboard
npm http GET https://registry.npmjs.org/zeroclipboard/-/zeroclipboard-2.3.0.tgz
npm http 200 https://registry.npmjs.org/numbro
npm http GET https://registry.npmjs.org/numbro/-/numbro-1.9.3.tgz
npm http 200 https://registry.npmjs.org/pikaday/-/pikaday-1.5.1.tgz
npm http 200 https://registry.npmjs.org/zeroclipboard/-/zeroclipboard-2.3.0.tgz
npm http 200 https://registry.npmjs.org/numbro/-/numbro-1.9.3.tgz
npm http 200 https://registry.npmjs.org/moment
npm http GET https://registry.npmjs.org/moment/-/moment-2.18.1.tgz
npm http 200 https://registry.npmjs.org/moment/-/moment-2.18.1.tgz
handsontable@0.31.1 node_modules/handsontable
├── zeroclipboard@2.3.0
├── numbro@1.9.3
├── pikaday@1.5.1
└── moment@2.18.1
cs


2) handsontable 기본 사용법은 아래 링크 참조

http://www.antilibrary.org/1156





3. Handsontable 앱 생성


1) Django app을 생성한다.

1
# python manage.py startapp handsontable
cs


1
2
3
4
5
6
7
8
9
10
11
12
# tree
.
├── __init__.py
├── admin.py
├── apps.py
├── migrations
│   └── __init__.py
├── models.py
├── tests.py
└── views.py
 
1 directory, 7 files
cs


2) 프로젝트 URL 설정

project/urls.py

1
2
3
4
5
urlpatterns = [
 
    url(r'^handsontable/', include('handsontable.urls''handsontable')),
 
]
cs


3) 앱 URL 설정

handsontable/urls.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from django.conf.urls import url
from django.views.generic.base import TemplateView
from django.views.generic.list import ListView
from .views import HandsonTableView
from .models import Header
 
# http://qiita.com/irxground/items/cd83786b10d81eecce77
urlpatterns = [
    url(r'^records/$',
        ListView.as_view(model=Header),
        name='record-index',
    ),
    url(r'^records/new$'
        TemplateView.as_view(template_name='handsontable/detail.html'),
        name='record-new'
    ),
    url(r'^records/(?P<pk>[0-9]+)/edit$'
        TemplateView.as_view(template_name='handsontable/detail.html'),
        name='record-edit'
    ),
    url(r'^ajax/records/(?P<pk>[0-9]+)$',
        HandsonTableView.as_view(),
        name='ajax',
    ),
]
cs


4) 예제에 사용할 모델을 만든다. 본 예제는 다음과 같이 Header와 Detail 이상 2개의 테이블로 구성된다. Header에서는 해당 품목의 id와 생성일자의 컬럼으로 구성되고, Detail 테이블에서 해당 품목(id)의 구매내역(구매자명, 일시, 구매가격)을 별도로 관리한다.

handsontable/models.py

1
2
3
4
5
6
7
8
9
10
11
from django.db import models
 
# Create your models here.
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)
cs


5) 마이그레이션을 실시한다.

1
2
3
4
5
6
7
# python manage.py makemigrations
 
Migrations for 'handsontable':
  0001_initial.py:
    - Create model Detail
    - Create model Header
    - Add field header to detail
cs


1
2
3
4
5
6
7
# python manage.py migrate
 
Operations to perform:
  Apply all migrations: handsontable
Running migrations:
  Rendering model states... DONE
  Applying handsontable.0001_initial... OK
cs




4. View 생성


리스트, 신규작성, 그리고 편집용 View 를 만든다.

handsontable/views.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import json
from django.views.generic import View, ListView
from django.http import HttpResponse
from django.core import serializers
from django.utils import timezone
from .models import Header, Detail
 
 
NEW_PAGE_ID = 0
 
class HandsonTableView(View):
    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'
        )
 
 
    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'))
        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()
 
            print(Header.objects.latest('id').id)
            return new_header
 
        header = Header.objects.filter(pk=pk).first()
        header.update_at = timezone.now()
        header.save()
        return header
cs




5. Serialization 모듈 설정


앞선 views.py의 get() 메서드에는 Model을 JSON화 시키고, JSON을 응답으로 돌려보내는 장치가 마련되어 있다. 프로젝트에 serializers 파일을 만들고 이를 settings.py 에 등록한다.


project/serializers.py

1
2
3
4
5
6
7
8
9
10
11
from django.core.serializers.json import Serializer
 
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
        self.json_kwargs['indent'= 2
cs


settings.py

1
2
3
SERIALIZATION_MODULES = {
    "handsontablejson""vikander.serializers"
}
cs




6. 템플릿 파일 생성


아래와 같이 2개의 템플릿을 만든다.

<Project Name>/<App Name>/templates/handsontable/header_list.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<div class="card-block">
    <p class="deck">
        <a href="{% url 'handsontable:record-new' %}">신규등록</a>
    </p>
    <p>
        <table class="table">
            <tr>
                <th><th>
                <th>갱신일시</th>
                <th>조작</th>
            </tr>
            {% for record in object_list %}
                <tr>
                    <td></tD>
                    <td>{{ record.id }} </td>
                    <td>{{ record.update_at }}</td>
                    <td><a href="{% url 'handsontable:record-edit' record.id %}">편집</a></td>
                </tr>
            {% endfor %}
        </table>    
    </p>
</div>
cs


<Project Name>/<App Name>/templates/handsontable/detail.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{% load static %}
<head>
    <link href="{% static 'assets/css/handsontable.full.min.css' %}" rel="stylesheet">
</head>
 
<div class="card-block">
    <p class="deck">
        <div id="grid"></div>
        <button type="button" id="add">Add</button>
        <button type="button" id="save">Save</button>
        <a href="{% url 'handsontable:record-index' %}">List</a>
        <script src="{% static 'assets/js/libs/handsontable.full.min.js' %}"></script>
        <script src="{% static 'assets/js/libs/js.cookie-2.1.3.min.js' %}"></script>
        <script src="{% static 'assets/js/libs/myscript.js' %}"></script>
    </p>
</div>
cs




7. Handsontable 오브젝트 설정



/static/assets/myscript.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
const NEW_PAGE_ID = 0;
 
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: ["Date""Name""Price"],
    rowHeaders: true,
    colWidths: [120200100]
});
 
var id = (() => {
    var found = location.pathname.match(/\/handsontable\/records\/(.*?)\/edit$/);
    return found ? found[1] : NEW_PAGE_ID;
})();
 
 
Handsontable.hooks.add('onAddRow', mydata => {
    table.alter('insert_row', data.length);
});
 
Handsontable.hooks.add('onSave', mydata => {
    var csrftoken = Cookies.get('csrftoken');
    
    fetch(`/handsontable/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('Saved');
            location.href = '/handsontable/records';
        }
        else{
            window.alert('Failed');
        }
    }).catch(err => console.error(err));
});
 
 
document.addEventListener("DOMContentLoaded", () => {
    fetch(`/handsontable/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.hooks.run(table, 'onSave', data);
});
 
document.getElementById('add').addEventListener('click', () => {
    Handsontable.hooks.run(table, 'onAddRow', data);
});
cs



/static/assets/js/libs/js.cookie-2.1.3.min.js

https://github.com/js-cookie/js-cookie/releases


/static/assets/js/libs/handsontable.full.min.js

https://github.com/handsontable/handsontable/releases


/static/assets/css/handsontable.full.min.css

https://github.com/handsontable/handsontable/releases



완성된 모습이다.


신규등록을 통해 품목을 새롭게 등록할 수 있다. 신규등록 클릭시 세부 판매 내역이 비어있는 detail.html 로 이동하게 된다. [Add] 버튼을 눌러서 행을 추가하고 데이터를 입력한다.




데이터 입력이 끝나고 [Save] 를 누르면 아래와 같이 저장에 성공했다는 메시지가 뜬다.


데이터베이스에 다음과 같이 저장이 되었다.


품목이 3개로 늘어났음을 확인할 수 있다.