【Python】DjangoでHandsontableをAjaxしてみる 【JaveScript】

#  はじめに
HandsontableというJavaScriptライブラリを使うと、WEBでExcelのようなスプレッドシートライクな入力が可能になります。が、更新方法が分からなかったので、いろいろ調べてみました。
  
なお、JavaScriptについては全くの初学者なので、おかしいところも多々あると思いますが、ご笑覧いただければ幸いです。

# できたもの

![2.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/689015/93b72200-f6a9-1263-ea2c-cab792f363f6.png)
データベース管理システム（PostgreSQL）からデータを取得し、Handsontableを使ってテーブルを表示。

![3.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/689015/59d47041-e176-01c2-6126-2d1468c99770.png)
ブラウザ上でデータを更新し(garlicをAPPLEへ)、上書きボタンをクリックすると…

![4.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/689015/06a9efc2-fd6d-b5c3-a9b2-ab3768648c02.png)
Ajax（非同期通信）を使って、ポスグレ上のデータを更新します。

![5.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/689015/ea0fcc40-ed80-1010-8a42-1a6c1e12813e.png)
念のため、管理サイトでも更新を確認。

# 準備
概要は以下の通りです。

- django admin startproject conf .
- タイムゾーン、言語の設定
- STATICFILES_DIRSの設定
- TEMPLATESの設定
- DATABASE(PostgreSQL)の設定

# アプリケーション作成
```
$ python manage.py startapp hot
```
アプリケーションを作成します。

```python:conf/settings.py
INSTALLED_APPS = [
    'hot.apps.HotConfig',
    # 以下省略
]
```
次にsettings.pyにアプリを登録します。

```python:conf/urls.py
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('hot/', include('hot.urls')
    # 以下省略
]
```
confフォルダのurls.pyを修正します。

```python:hot/urls.py
from django.urls import path
from  . import views


app_name = 'hot'

urlpatterns = [
    path('', views.Top.as_view(), name='top'),
    # path('ajax_update/', views.ajax_update, name='ajax_update')
]
```
hotフォルダ内にurls.pyを作成し、上記のように書きますクラスベースビューのTopはテーブルを表示するページで、関数ビューのajax_updateはデータを上書きするためのページです。Ajaxなので、ページ遷移はしません。

# モデルを作成

```python:hot/models.py
from django.db import models


class Merchandise(models.Model):
    merchandise = models.CharField('merchandise', max_length=255)
    price = models.IntegerField('price')
    origin = models.CharField('origin', max_length=255)

    def __str__(self):
        return self.merchandise
```
```python:hot/admin.py
from django.contrib import admin
from .models import Merchandise

admin.site.register(Merchandise)
```
```
$ python manage.py makemigrations hot
$ python manage.py migrate
$ python manage.py createsuperuser
```
モデルを作成します。商品名と価格と生産地をデータとしました。管理サイトへの登録、マイグレーション、スーパーユーザーの作成を忘れずに。

![1.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/689015/ef4f6128-1897-b2f7-2226-25d25a17db80.png)
適当に三つほどデータを入れておきます。

# テーブルを表示するページ(Top)を作成

```python:hot/views.py
import psycopg2
from django.http.response import JsonResponse
from django.shortcuts import render
from django.views import generic
from .models import Merchandise


class Top(generic.TemplateView):
    template_name = 'table/top.html'

    # contextをテンプレートファイルに渡す。
    def get_context_data(self, **kwargs):
        # はじめに継承元のメソッドを呼び出す
        context = super().get_context_data(**kwargs) 
        
        # データベースに接続→データ取得→切断
        connect_str  = ''
        connect_str += "dbname=test1 "
        connect_str += "user=postgres "
        connect_str += "password=password"
        conn = psycopg2.connect(connect_str)
        cur = conn.cursor()
        cur.execute('select * from hot_merchandise order by id')
        table = cur.fetchall()
        cur.close()
        conn.close()

        # handsontable用のデータを作成
        key = ('id', 'marchandise', 'price', 'origin')
        data = []
        for record in table:
            data.append(dict(zip(key, record)))
        columns = []
        for k in key:
            columns.append(dict(data=k))
        colHeaders = list(key)

        context['data'] = data
        context['columns'] = columns
        context['colHeaders'] = colHeaders
        return context
```
Topページのビューを作成します。データベースからデータを取得し、Handsontable用にかたちを整えて、テンプレートファイルに渡します。テーブルの名称はhot_merchandiseとなっています。アプリ名小文字_モデル名小文字になるみたいです。

```python:参考
# table(整形前のデータ）
[(1, 'carrot', 150, 'kanagawa'), (2, 'garlic', 200, 'aomori'), (3, 'onion', 300, 'hyogo')]

# data
[
    {'id': 1, 'merchandise': 'carrot', 'price': 150, 'origin': 'kanagawa'}, 
    {'id': 2, 'merchandise': 'garlic', 'price': 200, 'origin': 'aomori'}, 
    {'id': 3, 'merchandise': 'onion', 'price': 300, 'origin': 'hyogo'}
]

# columns
[
    {'data': 'id'}, 
    {'data': 'merchandise'}, 
    {'data': 'price'}, 
    {'data': 'origin'}
]

# colHeaders
['id', 'merchandise', 'price', 'origin']
```
ちなみに、handsontableに渡すデータの形状は上記のような感じです。

```html:base.html
{% load static %}
<!doctype html>
<html lang="ja">
  <head>
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

    <!-- Bootstrap CSS -->
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">

    <!-- handsontable -->
    <script src="{% static 'hot/jquery.min.js' %}"></script>
    <script src="{% static 'hot/handsontable.full.min.js' %}"></script>
    <link rel="stylesheet" href="{% static 'hot/handsontable.full.min.css' %}">

    <title>HandsontableでAjax</title>
  </head>
  <body>
    <!--  ナビゲーションバー -->
    <nav class="navbar navbar-expand-lg navbar-light bg-light">
      <a class="navbar-brand" href="{% url 'home:top' %}">Test</a>
      <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
        <span class="navbar-toggler-icon"></span>
      </button>
    
      <div class="collapse navbar-collapse" id="navbarSupportedContent">
        <ul class="navbar-nav mr-auto">
          <li class="nav-item">
            <a class="nav-link" href="{% url 'home:top' %}">Home</a>
          </li>
          <li class="nav-item">
            <a class="nav-link" href="{% url 'table:top' %}">Table</a>
          </li>
          <li class="nav-item">
            <a class="nav-link" href="{% url 'hot:top' %}">HOT</a>
          </li>
          <li class="nav-item">
            <a class="nav-link" href="{% url 'admin:index' %}">Admin</a>
          </li>
        </ul>
      </div>
    </nav>

    <div class="container">
        {% block content %}{% endblock %}
    </div>


    <!-- Optional JavaScript -->
    <!-- jQuery first, then Popper.js, then Bootstrap JS -->
    <!-- jQueryはslim版じゃないやつを使う。slimはajaxに対応していないため。 -->
    <script src="https://code.jquery.com/jquery-3.3.1.min.js" integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8=" crossorigin="anonymous"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js" integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49" crossorigin="anonymous"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js" integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy" crossorigin="anonymous"></script>
    {% block extrajs %}{% endblock %}
  </body>
</html>
```
ベースとなるテンプレートファイルを作成します。handsontableのjsやcssはCDNも公開されています。この記事では触れていないアプリ(home, table)へのリンクもありますが、そちらは無視してください。

```html:hot/top.html
{% extends 'base.html' %}
{% load static %}

{% block content %}
    <h3 class="mt-3">Table</h3>
    <div id="grid"></div>
    <div class="mt-1">
        <button type="button" id="btn_update_data" class="btn btn-primary">上書き</button>
    </div>
{% endblock %}

{% block extrajs %}
    <script>
        'use strict';

        // 変数の定義
        var data = {{ data | safe }};
        var columns = {{ columns | safe }};
        var colHeaders = {{ colHeaders | safe }};

        // テーブルを表示
        var container = document.getElementById('grid');
        var hot = new Handsontable(container, {
            data: data,
            columns: columns,
            columnSorting: true,
            rowHeaders: true,
            colHeaders: colHeaders,
            filters: true,
            dropdownMenu: true
        });
    </script>
{% endblock %}
```
top.htmlはbase.htmlをextendsします。block contentには見出しとテーブルと上書きボタンを設置しています。テーブルを書き込むdiv要素のid属性を'grid'としています。それを受けてextrajsブロックでgetElementById('grid')としています。

![2.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/689015/5da91c0e-30b2-febb-34b8-525f7bee30a8.png)

ブラウザ上ではこのような画面が表示されます。この時点でブラウザ上でデータは編集可能ですが、データベースの更新はできないので、リロードすると元に戻ります。

![3.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/689015/94c629f8-0e6d-89af-9c30-f0bc7586a0bb.png)
↑ブラウザ上では編集できますが、更新はできません↓

![2.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/689015/2138aabf-2353-dece-f141-160768f356fa.png)
↑リロードすると元に戻ってしまいます。

# データを上書きする
データを上書き更新できるようにしたいと思います。おおまかな手順としては、テーブルの下に設置した上書きボタンを押下すると、その時点のデータを取得し、データベースを更新するようにしたいと思います。

```html:hot/top.html
{% block extrajs %}
    <script>
        // 省略

        // Ajaxでデータベースを更新
        function getCookie(name) {
            var cookieValue = null;
            if (document.cookie && document.cookie !== '') {
                var cookies = document.cookie.split(';');
                for (var i = 0; i < cookies.length; i++) {
                    var cookie = jQuery.trim(cookies[i]);
                    // Does this cookie string begin with the name we want?
                    if (cookie.substring(0, name.length + 1) === (name + '=')) {
                        cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
                        break;
                    }
                }
            }
            return cookieValue;
        }

        var csrftoken = getCookie('csrftoken');

        function csrfSafeMethod(method) {
            // these HTTP methods do not require CSRF protection
            return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method));
        }

        $.ajaxSetup({
            beforeSend: function (xhr, settings) {
                if (!csrfSafeMethod(settings.type) && !this.crossDomain) {
                    xhr.setRequestHeader("X-CSRFToken", csrftoken);
                }
            }
        });

        // ↑　ここまではおまじない

        $('#btn_update_data').on('click', function(event){
            event.preventDefault();

            // ボタンをクリックした時点のデータを取得
            var data_hot = hot.getData();
            console.log(data_hot);

            // Ajax実行
            $.ajax({
                'url': '{% url "hot:ajax_update" %}',
                'type': 'POST',
                'data': {
                    'temp_data': 'aaa',
                    'str_hot': JSON.stringify(data_hot),
                    'str_col': JSON.stringify(columns)
                },
                'dataType': 'json'
            })
            .done(function(data) {
                window.alert("done!!");
            })
            .fail(function(){
                window.alert("error!!");
            });
        });
    </script>
{% endblock %}
```
hot/top.htmlのextrajsブロックにスクリプトを追記します。前半はおまじないらしく、よくわかりません…。 
  
 ```$('#btn_update_data').on('click', function(event)```以降がデータの上書きです。上書きボタンがクリックされたら、データを取得します。次にhot:ajax_upで指定した関数を実行するのですが、その時にdataを渡します。dataの中身は'temp_data'と'str_hot'と'str_col'です。'temp_data'は適当なデータです。'str_hot'はテーブルのデータをJSON形式に変換したものです。今回はJavaScript(Handsontable)からPythonへデータを渡すのですが、いろいろ調べてなんとかこの形にたどり着きました。（もっと良い方法があったら知りたい…）  
  
```python:hot/urls.py
app_name = 'hot'

urlpatterns = [
    path('', views.Top.as_view(), name='top'),
    path('ajax_update/', views.ajax_update, name='ajax_update')  # 追加
]
```
top.htmlにhot:ajax_updateというURLを指定しましたが、その様なURLは無いので作ります。次にviews.pyにajax_updateという関数ビューを作ります。

```python:hot/views.py
def ajax_update(request):
    # POSTされたデータを受け取る。この時点では文字列。
    str_hot = request.POST.get('str_hot')  # リストっぽい文字列[[1, "apple", 200, "aomori"], ...]
    str_col = request.POST.get('str_col')  # リストっぽい文字列[{'data':'id'}, {'data':'merchandise}, ...]
    
    # 文字列をリストに変換
    exec('data_hot={}'.format(str_hot), globals())
    exec('data_col={}'.format(str_col), globals())
    lis_col = []
    for elm in data_col:
        lis_col.append(elm['data'])

    # 辞書を作成
    # [{'id':1, 'marchandise'='apple', 'price':100, 'origin':"aomori"}, {...}, ...]
    lis_dic = []
    for row in data_hot:
        lis_dic.append(dict(zip(lis_col, row)))


    # データベースに接続
    connect_str  = ''
    connect_str += "dbname=test1 "
    connect_str += "user=postgres "
    connect_str += "password=password"
    conn = psycopg2.connect(connect_str)
    cur = conn.cursor()

    # データ更新
    for dic in lis_dic:
        id = dic['id']
        sql = 'UPDATE hot_merchandise SET'
        for k,v in dic.items():
            if k != 'id':
                if type(v) is str:
                    sql += f" {k}='{v}',"
                elif type(v) is int or type(v) is float:
                    sql += f" {k}={v},"
        sql = sql[:-1]  # 末尾のカンマを削除
        sql += f" where id={id};"
        print(sql)
        # 参考: "UPDATE hot_merchandise SET marchandise='orange', price=200, origin='aomori' WHERE id=1"
        cur.execute(sql)
        conn.commit()

    # データベース切断
    cur.close()
    conn.close()

    # レスポンスを返さないといけないらしいので、適当に…
    return JsonResponse({'ret': 'return'})
```
いよいよデータベースの更新です。いろいろ書いてありますが、やっていることはデータを受け取り、形を整えて、SQL文を生成し、実行の繰り返しです。ポイントは、ajaxで送られてきたデータは一見リストの様な形式をしていますが文字列です。なので、それをリストに戻してやる必要があります。いろいろ試した結果exec関数を使うことになりました。（もっと良い方法があったら知りたい…切実に…）  
  
# 実行してみる
![3.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/689015/960e1656-d156-46b1-593c-16ba1199a73a.png)
ブラウザ上でデータを更新し、上書きボタンをクリックします。

![4.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/689015/3fc64de3-5a5b-67fd-d8c0-4b2adf9a6731.png)
データの更新がうまくいけば、このようなウィンドウが開きます。ページ遷移はしません。

![3.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/689015/2b3b6513-7595-c06f-337c-01c3b644ef27.png)
前回はリロードすると元に戻ってしまいましたが、今回は大丈夫です。

![5.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/689015/17b2dfe6-16a5-a845-3eaa-91d3edb29d06.png)
管理サイト上でもデータベースが更新されています！

# おわりに
なんとか動いてよかった…。あやふやなところがたくさんあるので、少しずつ勉強していきたいと思います。