HerokuにDjangoアプリをデプロイするとcollectstaticが自動実行される

HerokuにDjangoアプリをデプロイしたところ、

remote:  !     Error while running '$ python manage.py collectstatic --noinput'.
remote:        See traceback above for details.
remote: 
remote:        You may need to update application code to resolve this error.
remote:        Or, you can disable collectstatic for this application:
remote: 
remote:           $ heroku config:set DISABLE_COLLECTSTATIC=1
remote: 
remote:        https://devcenter.heroku.com/articles/django-assets
remote:  !     Push rejected, failed to compile Python app.
remote: 
remote:  !     Push failed
remote: Verifying deploy...
remote: 
remote: !   Push rejected to <your app>.

というエラーメッセージが表示されてデプロイできなかった時のメモです。

 
その時のDjangoアプリは、

という構成でした。

 
エラーメッセージより、collectstatic不要なら環境変数DISABLE_COLLECTSTATICを設定すれば良さそうでした。たしかに今回のDjangoアプリは静的ファイルがないので、collectstaticは不要です。

 
また、エラーメッセージの中にあったURLを見たところ、

When a Django application is deployed to Heroku, $ python manage.py collectstatic --noinput is run automatically during the build. A build will fail if the collectstatic step is not successful.

Collectstatic during builds | Django and Static Assets | Heroku Dev Center

と、Herokuへのデプロイ時にはcollectstaticが自動実行されるとのことでした。

 
そこで、

$ heroku config:set DISABLE_COLLECTSTATIC=1
Setting DISABLE_COLLECTSTATIC and restarting ⬢ <your app>... done, v3
DISABLE_COLLECTSTATIC: 1

と、Heroku環境変数を設定しました。

その後、再度デプロイしたところ、問題なく完了しました。

DjangoをHeroku + uWSGIで動かしてみた

最近uWSGIにふれたため、HerokuでuWSGIを動かしてみようと思いました。

ただ、Herokuのチュートリアルではgunicornを動かしていました。
Getting Started on Heroku with Python | Heroku Dev Center

HerokuでuWSGIで動かす方法を調べたところ、uWSGIのドキュメントに記載がありました。
How to use Django with uWSGI | Django documentation | Django

そこで今回は、Django + uWSGIアプリをHerokuで動かしてみることにしました。

目次

   

環境

  • Mac OS X 10.11.6
  • Herokuアカウントは登録済、他は何もしていない
  • Python 3.5.2
  • Django 1.10.4
    • アプリの構成
      • PostgreSQL (9.6.1) を使用
        • Heroku上でmigrateやloaddataできるかも試す
      • 静的ファイルの配信あり
  • uWSGI 2.0.14
  • Djangoアプリ用のPythonライブラリ
    • dj-database-url0.4.1
    • psycopg2 2.6.2
    • whitenoise 3.2.2

 

MacでHeroku環境の準備

Homebrewによる Heroku Toolbeltのインストール

HomebrewでHeroku Toolbeltを管理したいため、Homebrewでインストールします。

# インストール
$ brew install heroku-toolbelt
...
🍺  /usr/local/Cellar/heroku/5.6.1-0976cf3: 12,942 files, 79.7M, built in 2 minutes 18 seconds

 

SSH鍵の登録

Herokuへpushする時に使用するSSH鍵を生成し、Herokuに登録します。
Managing Your SSH Keys | Heroku Dev Center

# 鍵生成
$ ssh-keygen -t rsa -b 4096 -C "you@example.com" -f ~/.ssh/id_rsa_heroku

# Herokuへログイン
$ heroku login
Enter your Heroku credentials.
Email: <typing>
Password (typing will be hidden): <typing>
Logged in as you@example.com

# HerokuにSSH鍵を追加
$ heroku keys:add ~/.ssh/id_rsa_heroku.pub
Uploading ~/.ssh/id_rsa_heroku.pub SSH key... done

 

MacPostgreSQLの準備

HomebrewでPostgreSQLをインストール

以下を参考に、HomebrewでPostgreSQLをインストールします。
MacにPostgreSQLをインストール - Qiita

# インストール
$ brew install postgresql
==> Installing dependencies for postgresql: readline
...
🍺  /usr/local/Cellar/readline/7.0.1: 46 files, 2M
==> Installing postgresql
...
==> /usr/local/Cellar/postgresql/9.6.1/bin/initdb /usr/local/var/postgres
==> Caveats
If builds of PostgreSQL 9 are failing and you have version 8.x installed,
you may need to remove the previous version first. See:
  https://github.com/Homebrew/homebrew/issues/2510

To migrate existing data from a previous major version (pre-9.0) of PostgreSQL, see:
  https://www.postgresql.org/docs/9.6/static/upgrading.html

To migrate existing data from a previous minor version (9.0-9.5) of PostgreSQL, see:
  https://www.postgresql.org/docs/9.6/static/pgupgrade.html

  You will need your previous PostgreSQL installation from brew to perform `pg_upgrade`.
  Do not run `brew cleanup postgresql` until you have performed the migration.

To have launchd start postgresql now and restart at login:
  brew services start postgresql
Or, if you don't want/need a background service you can just run:
  pg_ctl -D /usr/local/var/postgres start
==> Summary
🍺  /usr/local/Cellar/postgresql/9.6.1: 3,242 files, 36.4M

 

initdbの実行

以下を参考にinitdbを実行します。
initdb - PostgreSQL 9.6.1文書

initdb時の設定は以下の通りです。

# initdbの実行
$ initdb /usr/local/var/postgres -E utf8 --locale=C
The files belonging to this database system will be owned by user "<your_name>".
This user must also own the server process.

The database cluster will be initialized with locale "C".
The default text search configuration will be set to "english".

Data page checksums are disabled.

# エラー出た
initdb: directory "/usr/local/var/postgres" exists but is not empty
If you want to create a new database system, either remove or empty
the directory "/usr/local/var/postgres" or run initdb
with an argument other than "/usr/local/var/postgres".

 
エラーが出たためディレクトリを確認します。

$ cd /usr/local/var/
$ ls -al
total 0
drwxrwxr-x   6 you  admin  204 12 29 07:01 .
drwxr-xr-x  14 root          wheel  476 11 14 11:03 ..
drwxr-xr-x   4 you  admin  136 10 26 18:26 homebrew
drwxr-xr-x   2 you  admin   68 12  9 13:33 log
drwxr-xr-x  19 you  admin  646 12 17 07:21 mysql
drwx------  24 you  admin  816 12 29 07:01 postgres

 
エラーメッセージ通り、postgresディレクトリがすでに存在しているため、これを削除します。

# postgresディレクトリを削除
$ rm -r /usr/local/var/postgres
$ ls -al
total 0
drwxrwxr-x   5 you  admin  170 12 29 07:09 .
drwxr-xr-x  14 root          wheel  476 11 14 11:03 ..
drwxr-xr-x   4 you  admin  136 10 26 18:26 homebrew
drwxr-xr-x   2 you  admin   68 12  9 13:33 log
drwxr-xr-x  19 you  admin  646 12 17 07:21 mysql

 
もう一度initdbします。

# initdbの実行
$ initdb /usr/local/var/postgres -E utf8 --locale=C
...
WARNING: enabling "trust" authentication for local connections
You can change this by editing pg_hba.conf or using the option -A, or
--auth-local and --auth-host, the next time you run initdb.

Success. You can now start the database server using:

    pg_ctl -D /usr/local/var/postgres -l logfile start

WARNINGは出ているものの、今回は開発環境のため無視します。

これでインストールが終わりました。

 

Mac起動時にPostgreSQL自動起動させる

インストール時の最後のメッセージに自動起動設定に関するメッセージが出ていました。

ただ、brew servicesでも管理できるとのことなので、以下を参考にHomebrewで自動起動設定を行います。
OS X に PostgreSQL を Homebrew でインストールして brew services で起動する - Qiita

# 自動起動設定
$ brew services start postgresql
==> Successfully started `postgresql` (label: homebrew.mxcl.postgresql)

$ brew services list
Name         Status  User         Plist
chromedriver stopped
mysql        stopped
postgresql   started <your_name> ~/Library/LaunchAgents/homebrew.mxcl.postgresql.plist

 

データベース確認

現在のデータベースを確認します。

# データベースの確認
$ psql -l
                                   List of databases
   Name    |    Owner     | Encoding | Collate | Ctype |       Access privileges
-----------+--------------+----------+---------+-------+-------------------------------
 postgres  | <your_name>  | UTF8     | C       | C     |
 template0 | <your_name>  | UTF8     | C       | C     | =c/<your_name>              +
           |              |          |         |       | <your_name>=CTc/<your_name>
 template1 | <your_name>  | UTF8     | C       | C     | =c/<your_name>              +
           |              |          |         |       | <your_name>=CTc/<your_name>
(3 rows)

 

ユーザの作成

Django向けに、postgresユーザを作成します。開発環境なので、パスワードは無しです。

まずは現在のユーザの一覧を確認します。

# 現在のユーザ一覧を確認
$ psql -q -c'select * from pg_user' postgres
   usename    | usesysid | usecreatedb | usesuper | userepl | usebypassrls |  passwd  | valuntil | useconfig
--------------+----------+-------------+----------+---------+--------------+----------+----------+-----------
 <your_name>  |       10 | t           | t        | t       | t            | ******** |          |

 
続いて、ユーザを作成します。

# ユーザを作成
$ createuser postgres

# 作成したユーザがいるか確認
$ psql -q -c'select * from pg_user' postgres
   usename    | usesysid | usecreatedb | usesuper | userepl | usebypassrls |  passwd  | valuntil | useconfig
--------------+----------+-------------+----------+---------+--------------+----------+----------+-----------
 <your_name>  |       10 | t           | t        | t       | t            | ******** |          |
 postgres     |    16386 | f           | f        | f       | f            | ******** |          |
(2 rows)

 

作成したユーザがオーナーのdatabaseを作成

postgresユーザがオーナーのdatabaseを作成します。

今回のデータベース名はtry_heroku_django_uwsgiとします。

# データベースを作成、Ownerは`postgres`ユーザ
$ createdb -O postgres try_heroku_django_uwsgi

# 作成したデータベースの確認
$ psql -l
                                          List of databases
          Name           |    Owner     | Encoding | Collate | Ctype |       Access privileges       
-------------------------+--------------+----------+---------+-------+-------------------------------
 postgres                | <your_name>  | UTF8     | C       | C     |
 template0               | <your_name>  | UTF8     | C       | C     | =c/<your_name>               +
                         |              |          |         |       | <your_name> =CTc/<your_name>
 template1               | <your_name>  | UTF8     | C       | C     | =c/<your_name>               +
                         |              |          |         |       | <your_name> =CTc/<your_name>
 try_heroku_django_uwsgi | postgres     | UTF8     | C       | C     |
(4 rows)

 
以上でMacPostgreSQLの準備が終わりました。

 

MacDjango + uWSGIアプリを作成

Django, uWSGIなどのPythonライブラリをインストール

virtualenvを使ってインストールします。

$ mkdir try_heroku_django_postgres
$ cd try_heroku_django_postgres/

# pyenvを有効化してvirtualenvを作成
$ eval "$(pyenv init -)"
$ python --version
Python 3.5.2
$ virtualenv env
...

# 必要なライブラリをインストール
$ source env/bin/activate
(env) $ pip install django uwsgi dj_database_url psycopg2 whitenoise
...
Successfully installed django-1.10.4 uwsgi-2.0.14 dj-database-url-0.4.1 psycopg2-2.6.2 whitenoise-3.2.2

 

Djangoアプリの作成
プロジェクトとアプリの作成
(env) $ django-admin startproject myproject .
(env) $ python manage.py startapp myapp

 

コードを書く

昔の自分のメモを参考に、今回の内容に合わせて作成します。 Django + Herokuでdj-ringo-tabetterを作った時の作業メモ - メモ的な思考的な

ソースコード全体をGitHubに上げましたので、詳しい内容は省略します。

作成した時のポイントは以下の通りです。

 

migrationとloaddata

Mac上でmigrationとloaddataができるかを試します。

(env) $ python manage.py makemigrations
...
(env) $ python manage.py migrate
...
(env) $ python manage.py loaddata initial_data
Installed 2 object(s) from 1 fixture(s)

 

requirements.txtを作成

HerokuでPythonライブラリをインストールするため、requirements.txtを作成します。

(env) $ pip freeze > requirements.txt

 

gitリポジトリの作成

Herokuへpushするため、gitリポジトリを作成します。

なお、今回はこのリポジトリ専用のuser.nameとuser.emailも設定します。

$ git init .

# リポジトリ専用のuser.nameとuser.emailを設定
$ git config user.name "<your_name>"
$ git config user.email "<your_email>@example.com"

# 確認
$ git config user.name
your_name
$ git config user.email
<your_email>@example.com

$ git add .
$ git commit -m 'add samples'

 

Heroku環境の構築

Herokuへログイン
# ログイン
(env)$ heroku login
Enter your Heroku credentials.
# メールアドレスを入力
Email: <you@example.com>
# パスワードを入力
Password (typing will be hidden):
Logged in as <you@example.com>

 

Herokuアプリを作成

今回は開発的なものなので、アプリ名はデフォルトのままにします。

# Herokuアプリを作成
(env)$ heroku create
Creating app... done, ⬢ <foo-bar-1234>
https://<foo-bar-1234>.herokuapp.com/ | https://git.heroku.com/<foo-bar-1234>.git

 

Heroku Postgresを追加
# Heroku Postgresを追加
(env)$ heroku addons:create heroku-postgresql:hobby-dev
Creating heroku-postgresql:hobby-dev on ⬢ <foo-bar-1234>... free
Database has been created and is available
 ! This database is empty. If upgrading, you can transfer
 ! data from another database with pg:copy
Created postgresql-<ham-5678> as DATABASE_URL
Use heroku addons:docs heroku-postgresql to view documentation

 

DjangoアプリのリポジトリをHerokuへpushしてデプロイ
# Herokuへpush
(env)$ git push heroku master
...
remote: Compressing source files... done.
remote: Building source:
remote:
remote: -----> Python app detected
# runtime.txtで指定したバージョンのPythonがインストールされる
remote: -----> Installing python-3.5.2
# requirements.txtで指定したPythonライブラリがインストールされる
remote:      $ pip install -r requirements.txt
...
remote:        Successfully installed Django-1.10.4 dj-database-url-0.4.1 psycopg2-2.6.2 uWSGI-2.0.14 whitenoise-3.2.2
# collectstaticが実行される
remote:      $ python manage.py collectstatic --noinput
remote:        62 static files copied to '/tmp/build_9e36a428ccb437382bf6801f5cf09071/staticfiles'.
remote:
remote: -----> Discovering process types
remote:        Procfile declares types -> web
...
# デプロイ成功
remote: Verifying deploy... done.
To https://git.heroku.com/<foo-bar-1234>.git
 * [new branch]      master -> master

 

Heroku上での作業

環境変数の確認

ON_HEROKU環境変数が読み込まれていることを確認します。

# 環境変数の確認
(env)$ heroku config

DATABASE_URL: <url>
ON_HEROKU:    yes

 
もし設定されていない場合は、以下の方法で設定します。

# 環境変数の追加
(env)$ heroku config:set ON_HEROKU=yes
Setting ON_HEROKU and restarting ⬢ <foo-bar-1234>.. done, v6
ON_HEROKU: yes

 

migrateとloaddata

Djangoアプリのmigrateとloaddataを行います。

# migrate
$ heroku run python manage.py migrate
Running python manage.py migrate on ⬢ <foo-bar-1234>... up, run.9469 (Free)
...

  Applying sessions.0001_initial... OK

# loaddata
$ heroku run python manage.py loaddata initial_data
Running python manage.py loaddata initial_data on ⬢ <foo-bar-1234>... up, run.8331 (Free)
Installed 2 object(s) from 1 fixture(s)

 

Herokuアプリの確認

ブラウザが開くので、動作を確認します。

# Herokuアプリを開く
$ heroku open

 

uWSGIでホストされてることを確認

Herokuのログを見て、uWSGIで動いていることを確認します。

# Herokuのログを見る
$ heroku logs
...
heroku[web.1]: Process exited with status 0
heroku[web.1]: Starting process with command `uwsgi uwsgi_heroku.ini`
app[web.1]: [uWSGI] getting INI configuration from uwsgi_heroku.ini
app[web.1]: *** Starting uWSGI 2.0.14 (64bit) on [xxx] ***
app[web.1]: compiled with version: 4.8.4 on xxx
app[web.1]: os: Linux-3.13.0-105-generic #152-Ubuntu SMP xxx
app[web.1]: machine: x86_64
app[web.1]: nodename: xxx
app[web.1]: clock source: unix
app[web.1]: detected number of CPU cores: 8
app[web.1]: pcre jit disabled
app[web.1]: current working directory: /app
app[web.1]: detected binary path: /app/.heroku/python/bin/uwsgi
app[web.1]: your processes number limit is 256
app[web.1]: your memory page size is 4096 bytes
app[web.1]: detected max file descriptor number: 10000
app[web.1]: lock engine: pthread robust mutexes
app[web.1]: thunder lock: disabled (you can enable it with --thunder-lock)
app[web.1]: uwsgi socket 0 bound to TCP address :xxx fd 3
app[web.1]: Python version: 3.5.2 (default, Jun 28 2016, 18:49:03)  [GCC 4.8.4]
app[web.1]: *** Python threads support is disabled. You can enable it with --enable-threads ***
app[web.1]: Python main interpreter initialized at 0x2854720
app[web.1]: your server socket listen backlog is limited to 100 connections
app[web.1]: your mercy for graceful operations on workers is 60 seconds
app[web.1]: mapped 145536 bytes (142 KB) for 1 cores
app[web.1]: *** Operational MODE: single process ***
app[web.1]: WSGI app 0 (mountpoint='') ready in 1 seconds on interpreter 0x2854720 pid: 4 (default app)
app[web.1]: *** uWSGI is running in multiple interpreter mode ***
app[web.1]: spawned uWSGI worker 1 (pid: 7, cores: 1)
app[web.1]: spawned uWSGI master process (pid: 4)
heroku[web.1]: State changed from starting to up
heroku[router]: at=info method=GET path="/" host=<foo-bar-1234>.herokuapp.com request_id=xxx fwd="xxx.xxx.xxx.xxx" dyno=web.1 connect=0ms service=88ms status=200 bytes=559
app[web.1]: {address space usage: 288747520 bytes/275MB} {rss usage: 30261248 bytes/28MB} [pid: 7|app: 0|req: 1/1] xxx.xxx.xxx.xxx () {48 vars in 896 bytes} [xxx] GET / => generated 471 bytes in 87 msecs (HTTP/1.1 200) 2 headers in 88 bytes (1 switches on core 0)
heroku[router]: at=info method=GET path="/static/image/shinanogold.png" host=<foo-bar-1234>.herokuapp.com request_id=xxx fwd="xxx.xxx.xxx.xxx" dyno=web.1 connect=0ms service=3ms status=200 bytes=5762
app[web.1]: {address space usage: 288747520 bytes/275MB} {rss usage: 30441472 bytes/29MB} [pid: 7|app: 0|req: 2/2] xxx.xxx.xxx.xxx () {50 vars in 985 bytes} [xxx] GET /static/image/shinanogold.png => generated 5584 bytes in 2 msecs via sendfile() (HTTP/1.1 200) 5 headers in 178 bytes (0 switches on core 0)

uWSGIで動いているようです。

 

ソースコード

GitHubにあげました。
thinkAmi-sandbox/Django_on_Heroku_uWSGI-sample

2016年の振り返りと2017年の目標

昨年も振り返りと目標を立てていたので、今年も行います。

 

目標の振り返り

2015年の振り返りと2016年の目標 - メモ的な思考的なで立てた目標を振り返ってみます。

 

できる限り手を動かして、何かのアプリを作る

手は動かしていましたが、何かのアプリを作ることはできませんでした。

ただ、写経ではあるもののPythonWSGIサーバを作りました。

 
WSGIサーバの作成を通じて、Webサーバの動作やWSGIに関する知識を深めることができました。

そのおかげで最近いろいろと助かっています。

 

SCとる

無事にとれました。

あとは論文のある試験だけなので、これで一段落といったところです。

 

その他

GitHub

f:id:thinkAmi:20161231230606p:plain

 

転職

今までWindowsPython書いていましたが、MacPythonを書くようになりました。ご縁に感謝しています。

まわりはできる方々ばかりで、常に良い刺激が得られる環境となりました。ありがたい限りです。

また、テストコードが必要な環境に置かれているのも良い感じです。

 

人生の夏休み

一ヶ月ほど人生の夏休みをとりました。

いろいろとやったような気もしますが、何もできなかったような気もします。

それでも、お世話になった方々へのご挨拶などができ、気持ちの切り替えにつながりました。

 

勉強会

2015年に比べ、勉強会への参加が増えました。

  1. デブサミ2016の二日目に行ってきました #devsumi - メモ的な思考的な
  2. Tokyo ComCamp 2016 powered by MVPs に参加しました #JCCMVP - メモ的な思考的な
  3. Android Study Jams&機械学習予習会に行ってきました #GDG信州 - メモ的な思考的な
  4. Google I/O報告会 2016 in 信州に参加しました #io16jp #GDG信州 - メモ的な思考的な
  5. #stapy #glnagano 第7回 Python勉強会 in GEEKLAB.NAGANOに参加しました - メモ的な思考的な
  6. #gcpug #glnagano GCPUG信州 キックオフ勉強会(機械学習/TensorFlow)に参加しました - メモ的な思考的な
  7. #nseg #jxug #glnagano JXUGC No.19 Xamarin ハンズオン 長野大会 featuring NSEG に参加しました - メモ的な思考的な

 

2017年の目標っぽいもの

まだ生活リズムが整っていないこともあり、

  • Pythonの基礎知識を身につける
  • MacLinuxに慣れる
  • 生活リズムを整える

と、昨年よりもさらに抽象的な目標としたいと思います。

少なくとも生活リズムを整えて身近なイベントには参加したいところです*1

 
そんな感じですが、今年もよろしくお願いします。

*1:が、少なくともあと半年は見通しが立たなかったりします...

Python + pytestで、プレフィクスがアンダースコア2つの関数(プライベート関数)をテストする

pytestにて、プライベート関数のテストで悩んだことがあったため、メモを残します。

なお、今回のテスト対象コードは以下とします。

target.py

def __double_underscore_function():
    return 'double'

 
目次

 

環境

  • Windows10
  • Python 3.6.0 (32bit)
  • pytest 3.0.5

 

プライベート関数のimportについて

通常のコードの場合

Pythonでは変数・関数・メソッド名の先頭に_ (アンダースコア)があると、それらはプライベートなものとして扱われます。

 
ただ、プライベート関数の場合

standard_usage.py

from target import __double_underscore_function
import target

def main():
    print(f'from import: {__double_underscore_function()}')
    print(f'import: {target.__double_underscore_function()}')

if __name__ == '__main__':
    main()

と関数の中で使った場合でも

>python standard_usage.py
from import: double
import: double

と正常に動作します。

 
一方、

from target import __double_underscore_function

class Main(object):
    def run_with_import_from(self):
        print(f'from import: {__double_underscore_function()}')

    def run_with_import(self):
        print(f'import: {target.__double_underscore_function()}')

if __name__ == '__main__':
    m = Main()
    m.run_with_import()

と、クラスの中で使った場合

  • run_with_import_from()の場合、NameError: name '_Main__double_underscore_function' is not defined
  • run_with_import()の場合、AttributeError: module 'target' has no attribute '_Main__double_underscore_function'

という例外が送出されます。

マングリングっぽい動きです。
9.6. プライベート変数 | 9. クラス — Python 3.5.2 ドキュメント

 

テストコードの場合

テストコードの場合も同様で、

test_pytest_ver.py

import pytest
from target import __double_underscore_function
import target


class Test_function(object):
    def test_double_underscore_prefix_function_using_from_import(self):
        assert __double_underscore_function() == 'double'

    def test_double_undersocre_prefix_function_using_import(self):
        assert target.__double_underscore_function() == 'double'

と書くと、

>pytest test_pytest_ver.py
============================= test session starts =============================
platform win32 -- Python 3.6.0, pytest-3.0.5, py-1.4.32, pluggy-0.4.0
rootdir: path\to\dir, inifile: pytest.ini
collected 2 items

test_pytest_ver.py FF

================================== FAILURES ===================================
___ Test_function.test_double_underscore_prefix_function_using_from_import ____

self = <test_pytest_ver.py.Test_function object at 0x04530930>

    def test_double_underscore_prefix_function_using_from_import(self):
>       assert __double_underscore_function() == 'double'
E       NameError: name '_Test_function__double_underscore_function' is not defined

test_pytest_ver.py.py:8: NameError
______ Test_function.test_double_undersocre_prefix_function_using_import ______

self = <test_pytest_ver.py.Test_function object at 0x045274D0>

    def test_double_undersocre_prefix_function_using_import(self):
>       assert target.__double_underscore_function() == 'double'
E       AttributeError: module 'target' has no attribute '_Test_function__double_underscore_function'

test_pytest_ver.py.py:11: AttributeError
========================== 2 failed in 0.19 seconds ===========================

エラーになり、テストが正常に実行できません。

 

対応

手元で動かしたところ、以下のどちらかの方法で対応できそうでした(ただ、他にもより良い方法があれば知りたいです)。

  • クラスの外側でテストする
  • import時にasエイリアスを付ける

 

クラスを使わないテストコードにする

クラスによるグループ化を諦めて、クラスを使わないテストコードにします。

test_pytest_ver.py

from target import __double_underscore_function

def test_double_underscore_prefix_function_using_from_import():
    assert __double_underscore_function() == 'double'

実行してみます。

>pytest test_pytest_ver.py
============================= test session starts =============================
platform win32 -- Python 3.6.0, pytest-3.0.5, py-1.4.32, pluggy-0.4.0
rootdir: path\to\dir, inifile: pytest.ini
collected 1 items

test_pytest_ver.py .

========================== 1 passed in 0.05 seconds ===========================

テストできました。

 

import時にasエイリアスを付ける

クラスでグループ化したい場合は、import ... asエイリアスを付けたテストコードにします。

test_pytest_ver.py

from target import __double_underscore_function as double_underscore_function

class Test_function(object):
    def test_double_undersocre_prefix_function_using_from_import_alias(self):
        assert double_underscore_function() == 'double'

実行してみます。

>pytest test_pytest_ver.py
============================= test session starts =============================
platform win32 -- Python 3.6.0, pytest-3.0.5, py-1.4.32, pluggy-0.4.0
rootdir: path\to\dir, inifile: pytest.ini
collected 1 items

test_tmp.py .

========================== 1 passed in 0.05 seconds ===========================

テストできました。

 

その他

プレフィクスがアンダースコア1つの場合

マングリングは働かないため、普通にimportしても動作します。

そのため、

# テスト対象のコード
def _single_underscore_function():
    return 'single'

というテスト対象コードに対して

# テストコード
from target import _single_underscore_function

class Test_function(object):
    def test_single_underscore_prefix_function_using_from_import(self):
        assert _single_underscore_function() == 'single'

と書けば動作します。

 

標準モジュールunittestの場合

unittestではunittest.TestCaseを継承してテストコードを書く必要があります。

そのため、import ... asとする方法しかなさそうです。

import unittest
from target import __double_underscore_function
import target
from target import __double_underscore_function as double_underscore_function


class Test_function(unittest.TestCase):
    @unittest.expectedFailure
    def test_double_underscore_prefix_function_using_from_import(self):
        self.assertEqual(__double_underscore_function(), 'double')
        # => NameError: name '_Test_function__double_function' is not defined

    @unittest.expectedFailure
    def test_double_undersocre_prefix_function_using_import(self):
        assert target.__double_underscore_function() == 'double'
        # => AttributeError: module 'target' has no attribute '_Test_function__double_underscore_function'

    def test_double_undersocre_prefix_function_using_from_import_alias(self):
        self.assertEqual(double_underscore_function(), 'double')
        # => pass


if __name__ == '__main__':
    unittest.main()

 

ソースコード

GitHubに上げました。test_double_underscore_prefix_moduleディレクトリ以下が今回のファイルです。
thinkAmi-sandbox/python_pytest-sample

Pythonのテストコードで、モジュールをモックに差し替える

Pythonにて、開発環境に無いモジュールをimportしているプロダクションコードに対して、テストコードを書く機会がありました。

ただ、テストコードにてモジュールをモックに差し替える方法で悩んだため、メモを残します。

 
目次

 

環境

 

対応

以下を参考に、sys.modules辞書の該当モジュールをモックに差し替えます。
python - How to mock an import - Stack Overflow

 
例えば、

# 開発環境で使えないモジュールたち
from disabled_package import disabled_module
from disabled_package.disabled_module import disabled_submodule

class Target(object):
    def square(self, value):
        # モジュールに含まれる関数(call_function())や定数(CONST)を使用
        disabled_module.call_function(disabled_submodule.CONST)

        return value ** 2

というコードがあり、モジュール

  • disabled_package
  • disabled_package.disabled_module

が開発環境で使えないとします。

 
この状態で

import pytest
from target import Target

class Test_Target(object):
    def test_square(self):
        # Arrange
        sut = Target()
        # Act
        actual = sut.square(2)
        # Assert
        assert actual == 4

というテストコードを作成・実行しても、

(env) >pytest
============================= test session starts =============================
platform win32 -- Python 3.5.2, pytest-3.0.5, py-1.4.32, pluggy-0.4.0
rootdir: path\to\python_mock_sample, inifile: pytest.ini
collected 0 items / 1 errors

=================================== ERRORS ====================================
_______________ ERROR collecting mocking_module/test_target.py ________________
ImportError while importing test module 'path\to\python_mock_sample\mocking_module\test_target.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
test_target.py:11: in <module>
    from target import Target
target.py:1: in <module>
    from disabled_package import disabled_module
E   ImportError: No module named 'disabled_package'
!!!!!!!!!!!!!!!!!!! Interrupted: 1 errors during collection !!!!!!!!!!!!!!!!!!!
=========================== 1 error in 0.33 seconds ===========================

失敗します。

 
そのため、テストコードを修正し、

import pytest
from unittest.mock import Mock

# モジュールをモックへ差し替えるように追加
import sys
sys.modules['disabled_package'] = Mock()
sys.modules['disabled_package.disabled_module'] = Mock()

from target import Target

class Test_Target(object):
    def test_square(self):
        # Arrange
        sut = Target()
        # Act
        actual = sut.square(2)
        # Assert
        assert actual == 4

テスト対象コードのimport前に、開発環境で使えないモジュールをモックへと差し替えます。

 
その後、テストを実行したところ、

(env) >pytest
============================= test session starts =============================
platform win32 -- Python 3.5.2, pytest-3.0.5, py-1.4.32, pluggy-0.4.0
rootdir: D:\Sandbox\python_mock_sample, inifile: pytest.ini
collected 1 items

test_target.py .

========================== 1 passed in 0.09 seconds ===========================

成功しました。

 
ちなみに、sys.modules

ロード済みモジュールのモジュール名とモジュールオブジェクトの辞書。

sys.modules | 28.1. sys — システムパラメータと関数 — Python 2.7.x ドキュメント

とのことです。

 
確認のため

import sys
import pytest

def main():
    print(sys.modules['pytest'])

を作成・実行してみたところ、

<module 'pytest' from 'path\\to\\python_mock_sample\\env\\lib\\site-packages\\pytest.py'>

と、モジュールオブジェクトが入っていました。

これにより、モジュールオブジェクトをモックに差し替えられることが分かりました。

 

ソースコード

GitHubに上げました。e.g_mocking_moduleディレクトリの中が今回のコードです。
thinkAmi-sandbox/python_mock-sample

DjangoにDjangoミドルウェアとWSGIミドルウェアを組み込んでみた

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

 
Djangoにはミドルウェアというフレームワークがあるため、リクエスト/レスポンス処理をフックして処理を追加できます。
Middleware | Django documentation | Django

また、DjangoWSGI規格に沿っていることから、WSGIミドルウェアでも処理を追加できます。
第3回 WSGIミドルウェアの作成:WSGIとPythonでスマートなWebアプリケーション開発を|gihyo.jp … 技術評論社

 
そこで今回は、

をそれぞれ作成し、Djangoに組み込んでみます。

 
目次

 

環境

 

ミドルウェアを組み込む前のDjangoアプリ

http://localhost:8000/myapp/にアクセスすると「Hello world」を表示するDjangoアプリを用意します。

myproject/urls.py

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

myapp/urls.py

urlpatterns = [
    url(r'^$',
        MyAppView.as_view(),
        name='index',
    ),
]

myapp/views.py

from django.views import View
from django.http import HttpResponse

class MyAppView(View):
    def get(self, request, *args, **kwargs):
        print('called: MyAppView')
        return HttpResponse('Hello world')

 

Djangoミドルウェアの組み込み

Django1.10以降の書き方のDjangoミドルウェアを組み込む

Django1.10より、Djangoミドルウェアの書き方が変更されました。そこで今回は1.10以降のDjangoミドルウェアの作法に従って書いてみます。

 
今回は各タイミングでprint()するミドルウェアを作成しました。

myproject/django_middleware_in_project.py

class DjangoMiddlewareInProject(object):
    def __init__(self, get_response):
        self.get_response = get_response
        print('proj: one-time cofiguration')

    def __call__(self, request):
        print('proj: before request')
        response = self.get_response(request)
        print('proj: after request')
        
        return response

 
MIDDLEWAREに追加します。

myproject/settings.py

MIDDLEWARE = [
    ...
    'myproject.django_middleware_in_project.DjangoMiddlewareInProject',
]

 
runserverし、http://localhost:8000/myapp/にアクセス後のログを見ます。

# 起動直後
(env) >python manage.py runserver
...
Django version 1.10.4, using settings 'myproject.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CTRL-BREAK.
proj: one-time cofiguration
proj: one-time cofiguration

# http://localhost:8000/myapp/にアクセス後
proj: before request
called: MyAppView
proj: after request

 
各タイミングでDjangoミドルウェアが動作していることが分かりました*1

 

複数のDjangoミドルウェアの動作順を確認する

複数Djangoミドルウェアを組み込んだ場合の動作順が気になったため、試してみます。

先ほど作成したDjangoMiddlewareInProjectに加え、それと同じように動作するミドルウェア

  • myapp/django_middleware_in_app1.py
  • myapp/django_middleware_in_app2.py

として用意します。

また、MIDDLEWAREの設定を以下とします。

MIDDLEWARE = [
    ...
    'myapp.django_middleware_in_app1.DjangoMiddlewareInApp1',
    'myproject.django_middleware_in_project.DjangoMiddlewareInProject',
    'myapp.django_middleware_in_app2.DjangoMiddlewareInApp2',
]

 
runserverし、http://localhost:8000/myapp/にアクセス後のログを見ます。

(env) >python manage.py runserver
...
app2: one-time cofiguration
proj: one-time cofiguration
app1: one-time cofiguration
app2: one-time cofiguration
proj: one-time cofiguration
app1: one-time cofiguration
app1: before request
proj: before request
app2: before request
called: MyAppView
app2: after request
proj: after request
app1: after request

 
配置場所(myproject/ or myapp/)に関係なく、組み込み順(app1 > project > app2)とは逆順(app2 > project > app1)で動作するようです。

 

WSGIミドルウェアの組み込み

wsgi_lineprofの組み込み

以下を参考に、WSGIミドルウェアwsgi_lineprofを組み込みます。
How to deploy with WSGI | Django documentation | Django

 
念のため、wsgi_lineprofのソースコードを確認すると、def __call__(self, env, start_response):がありました(このあたり)。

そのため、WSGIミドルウェアとしてDjangoに組み込めそうです。

 
まずはpipでインストールします。Windowsでも問題なくインストールできました。

(env) >pip install wsgi_lineprof
...
Successfully installed wsgi-lineprof-0.2.0

(env) >pip list
Django (1.10.4)
pip (7.1.2)
setuptools (18.2)
six (1.10.0)
wheel (0.24.0)
wsgi-lineprof (0.2.0)

 
続いて、WSGIミドルウェアを組み込みます。

myproject/wsgi.py

import os
from django.core.wsgi import get_wsgi_application

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")
application = get_wsgi_application()

# 以下を追加
from wsgi_lineprof.middleware import LineProfilerMiddleware
application = LineProfilerMiddleware(application)

 
runserverしてhttp://localhost:8000/myapp/にアクセス後のログを見ます。

...
File: path\to\django_project_dir\env\lib\site-packages\django\core\handlers\wsgi.py
Name: __init__
Total time: 6.1594e-06 [sec]
  Line      Hits         Time  Code
===================================
    32                             def __init__(self, stream, limit, buf_size=64 * 1024 * 1024):
    33         1            4          self.stream = stream
    34         1            2          self.remaining = limit
    35         1            2          self.buffer = b''
    36         1            4          self.buf_size = buf_size

[**/Dec/2016 **:**:**] "GET /myapp/ HTTP/1.1" 200 11

 
wsgi_lineprofが動作しています。

 

ミドルウェアでの例外ハンドリングについて

Djangoアプリで送出した例外のハンドリングについて

以前、WSGIアプリの例外をハンドリングするWSGIミドルウェアを作りました。
WSGIアプリの例外をハンドリングするWSGIミドルウェア | Pythonで、WSGIミドルウェアを作ってみた - メモ的な思考的な

 
そこで、今回も同様のWSGIミドルウェアを組み込んでみます。

まずは例外を出すViewを作成します。

myapp/views.py

class MyExceptionView(View):
    def get(self, request, *args, **kwargs):
        print('called: MyExceptionView')
        raise AssertionError(request)

        return HttpResponse('raised exception')

myapp/urls.py

urlpatterns = [
    ...
    url(r'^exception$',
        MyExceptionView.as_view(),
        name='exception',
    )
]

 
続いて、例外をハンドリングするWSGIミドルウェアを作成します。

myporject/wsgi_middleware_exception_handling.py

class WSGIMiddlewareExceptionHandling(object):
    def __init__(self, app):
        self.app = app
    
    def __call__(self, environ, start_response):
        print("called: WSGIMiddlewareExceptionHandling")

        try:
            return self.app(environ, start_response)
        except:
            print('handled exception by WSGIMiddlewareExceptionHandling')
            start_response('500 Internal Server Error', [('Content-Type', 'text/plain')])
            return [b"raised exception."]

 
最後にDjangoに組み込みます。

myproject/wsgi.py

application = get_wsgi_application()

# 以下を追加
from .wsgi_middleware_exception_handling import WSGIMiddlewareExceptionHandling
application = WSGIMiddlewareExceptionHandling(application)

 
runserverし、http://localhost:8000/myapp/exceptionにブラウザでアクセスすると、

AssertionError at /myapp/exception
<WSGIRequest: GET '/myapp/exception'>

Djangoのエラーページが表示されました。

 
ログを見ます。

(env) D:\Sandbox\Django_WSGI_middleware_sample>python manage.py runserver
...
app2: one-time cofiguration
proj: one-time cofiguration
app1: one-time cofiguration
app2: one-time cofiguration
proj: one-time cofiguration
app1: one-time cofiguration
called: WSGIMiddlewareErrorHandling
app1: before request
proj: before request
app2: before request
called: MyExceptionView
Internal Server Error: /myapp/exception
Traceback (most recent call last):
...
AssertionError: <WSGIRequest: GET '/myapp/exception'>
app2: after request
proj: after request
app1: after request
[**/Dec/2016 **:**:**] "GET /myapp/exception HTTP/1.1" 500 75449

「WSGIMiddlewareErrorHandling」は起動しているようです。

一方、「handled exception by WSGIMiddlewareErrorHandling」が表示されていないため、Djangoアプリが送出した例外をWSGIミドルウェアではハンドリングできていないようです。

 
Djangoのドキュメントを読むと、以下に例外ハンドリングに関する記載がありました。

 
意訳すると、

とようです。

 
そこで、その挙動を確認するDjangoミドルウェアを作成してみます。

myproject/django_middleware_exception_handling.py

class DjangoMiddlewareExceptionHandling(object):
    def __init__(self, get_response):
        self.get_response = get_response
        print('django_middleware: one-time cofiguration')

    def __call__(self, request):
        print('django_middleware: before request')

        try:
            response = self.get_response(request)
            print('Django response data:{}'.format(response))
        except:
            print('handled exception by DjangoMiddlewareExceptionHandling')

        print('django_middleware: after request')
        return response

    def process_exception(self, request, exception):
        print('handled exception by process_exception in DjangoMiddlewareExceptionHandling')

 
Djangoミドルウェアとして登録します。

myproject/settings.py

MIDDLEWARE = [
    ...
    # DjangoMiddlewareExceptionHandlingのみ有効化
    # 'myapp.django_middleware_in_app1.DjangoMiddlewareInApp1',
    # 'myproject.django_middleware_in_project.DjangoMiddlewareInProject',
    # 'myapp.django_middleware_in_app2.DjangoMiddlewareInApp2',
    'myproject.django_middleware_exception_handling.DjangoMiddlewareExceptionHandling',
]

 
再度http://localhost:8000/myapp/exceptionにアクセスし、ログを見ます。

(env) >python manage.py runserver
...
django_middleware: one-time cofiguration
django_middleware: one-time cofiguration
called: WSGIMiddlewareExceptionHandling
django_middleware: before request
called: MyExceptionView

# process_exception()の動作ログ
handled exception by process_exception in DjangoMiddlewareExceptionHandling
Internal Server Error: /myapp/exception
Traceback (most recent call last):
...
AssertionError: <WSGIRequest: GET '/myapp/exception'>

# get_response()コールバックの戻り値
Django response data:<HttpResponse status_code=500, "text/html">
django_middleware: after request
[**/Dec/2016 **:**:**] "GET /myapp/exception HTTP/1.1" 500 75197

 
これらより、

  • get_response()コールバックの戻り値に、ステータスコードなどがある
  • 例外をexceptした時のログなし
  • process_exception()が動作したときのログあり

ということが分かりました。

 
Djangoアプリの例外はDjangoミドルウェアでハンドリングできそうです。

 

WSGIミドルウェアで送出した例外のハンドリングについて

DjangoミドルウェアWSGIミドルウェアの挙動をみてみます。

まずは例外を送出するWSGIミドルウェアを作成します。

wsgi_middleware_raise_exception.py

class WSGIMiddlewareRaiseException(object):
    def __init__(self, app):
        self.app = app
    
    def __call__(self, environ, start_response):
        print('called: WSGIMiddlewareRaiseException')
        raise AssertionError

        self.app(environ, start_response)

 
Djangoに組み込みます。

myproject/wsgi.py

from .wsgi_middleware_raise_exception import WSGIMiddlewareRaiseException
application = WSGIMiddlewareRaiseException(application)

from .wsgi_middleware_exception_handling import WSGIMiddlewareExceptionHandling
application = WSGIMiddlewareExceptionHandling(application)

 
runserverし、http://localhost:8000/myapp/exceptionにブラウザでアクセスすると、

raised exception.

と表示されました。

 
ログを見ます。

(env) D:\Sandbox\Django_WSGI_middleware_sample>python manage.py runserver
...
django_middleware: one-time cofiguration
django_middleware: one-time cofiguration
called: WSGIMiddlewareExceptionHandling
called: WSGIMiddlewareRaiseException
handled exception by WSGIMiddlewareExceptionHandling
[**/Dec/2016 **:**:**] "GET /myapp/exception HTTP/1.1" 500 17

 
これらより、

ということが分かりました。

 

ソースコード

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

*1:起動直後に__init__()が2回呼ばれているのが気になりますけれど、環境のせいでしょうか コメントをいただきました。django.contrib.staticfilesがWSGIアプリケーションだったため、staticfilesとmyappの2回分呼ばれていました

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