kintoneと連携する帳票作成サーバを作ってみた

# やりたいこと
kintone（キントーン）に登録した顧客情報から、ご契約サービスの利用開始案内通知書を作りたいです。
顧客情報には、顧客コードと契約種類で決まる一意のURL、各サービスのご契約状況フラグが含まれています。

通知書のイメージはこんな感じで、URLから生成するQRコードの画像を帳票に埋め込む必要がありました。（会社名やURLは架空のものです）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/236222/7a559db2-e964-a8a0-8e92-5041933e1282.png)

kintone は、同じローコードツールの Salesforce に比べると（標準では）帳票系に弱く、実現にはプラグインや外部サービスを頼ることになりそうです。
今回は自前でやりたかったので、帳票作成サーバを立ててみました。

## サーバ構成
- Ubuntu 20.04 (Amazon EC2)
- Apache 2.4
- Python 3.8

## 使用したオープンソース
- [reportlab](https://pypi.org/project/reportlab/) - PDFファイルを生成するため
- [qrcode](https://pypi.org/project/qrcode/) - QRコードを生成するため
- [Flask](https://pypi.org/project/Flask/) - 帳票作成リクエストを受けつけるため（コマンドで起動するなら不要）

# APIサーバの構築
kintone もしくは人を通して、帳票作成リクエストを受けつけるのがAPIサーバの役割です。
Python実行環境（WindowsやMac、Linuxなど）から帳票作成スクリプトを起動するなら不要です。

## 各種インストール
Apache2 / Python3 は導入済みとします。
Pythonで書かれたWebアプリケーションをWebサーバ（Apache2）と接続するために、WSGI（Web Server Gateway Interface）を導入します。

```
apt update
apt -y upgrade
apt install apache2-dev
apt install libapache2-mod-wsgi-py3
```

```
pip3 install mod_wsgi
```

依存関係でエラーになったら、

```
pip3 uninstall importlib_metadata
pip3 install importlib_metadata --force-reinstall
```

で強制的に再インストールしてみて下さい。

最後にサードパーティのライブラリを入れます。
Flask はシンプルで軽量な、Pythonで動くWebアプリケーションフレームワークで、プロトタイプ開発などでよく活用されています。
よく比較されるDjango（ジャンゴ）は、大規模かつ複雑なアプリケーション開発に適したフルスタックフレームワーク、という棲み分けです。

```
pip3 install flask, reportlab, qrcode
```

## 環境設定
Flask の設定です。

```
mkdir /var/www/flask
chown -R www-data:www-data flask
```

```python:/var/www/flask/qrapp/wsgi.py
import sys

sys.path.insert(0, '/var/www/flask/qrapp')
from app import app as application
```

テスト用のサンプルアプリケーションです。

```python:/var/www/flask/qrapp/app.py
from flask import Flask

app = Flask(__name__)

@app.route('/')
def hello():
    return "Hello World!"

if __name__ == "__main__":
    app.run()
```

Apache2 と接続するための設定をします。
マルチバイト文字を含むファイル名などを作成すると `UnicodeEncodeError: 'ascii' codec can't encode characters in position ...` になる場合、`locale=ja_JP.UTF-8` も必要です。

```apache:/etc/apache2/sites-available/virtual-host.conf
WSGIDaemonProcess qrapp user=www-data group=www-data threads=5 locale=ja_JP.UTF-8
WSGIScriptAlias /qrapp /var/www/flask/qrapp/wsgi.py
<Directory /var/www/flask/qrapp>
    WSGIProcessGroup qrapp
    WSGIApplicationGroup %{GLOBAL}
    AllowOverride All
    Require all granted
</Directory>
```

Apache2 を再起動し、`https://example.com/qrapp` で `Hello World!` と表示されたらOK

```
systemctl restart apache2
```

# 実行してみよう
まず、完動するモジュールを先に示します。

```python:pdf_qr_generator.py
""" Generate PDF files guiding companies to product URLs with QR codes """
__author__ = "MindWood"
__version__ = "1.0.0"

import os
import datetime
import json
import tempfile
import zipfile
import requests
import qrcode
from reportlab.pdfgen import canvas
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.cidfonts import UnicodeCIDFont
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4, portrait
from reportlab.lib.units import mm
from reportlab.platypus import Table, TableStyle

CONFIG_PATH = 'config.json'
API_URL = 'https://foo.cybozu.com/k/guest/99/v1/records.json'
APP_ID = 1234
FONT_NAME = "HeiseiKakuGo-W5"

class PdfQrGenerator:
    """ Class to generate PDF files with QR codes and archive them into a ZIP file """

    def __init__(self):
        """ Load the API token from a JSON configuration file """
        if not os.path.exists(CONFIG_PATH):
            raise FileNotFoundError(f"Configuration file '{CONFIG_PATH}' not found.")
        with open(CONFIG_PATH, 'r') as config_file:
            config = json.load(config_file)
        if 'api_token' not in config:
            raise KeyError("API token not found in config.json.")
        PdfQrGenerator.__api_token = config['api_token']

        pdfmetrics.registerFont(UnicodeCIDFont(FONT_NAME))


    def generate(self, zip_file):
        """ Generate a zip file to store the pdf files """
        self.zip_file = zip_file
        with tempfile.TemporaryDirectory() as tmpd:
            self.tmp_dir = tmpd
            self._fetch_kintone_record()
            self._create_archive()


    def _fetch_kintone_record(self):
        """ Use kintone api to get properties of all registered companies """
        headers = {
            'X-Cybozu-API-Token': PdfQrGenerator.__api_token,
        }
        params = {
            'app': APP_ID,
        }
        response = requests.get(API_URL, headers=headers, params=params)
        response.raise_for_status()
        records = response.json().get('records', [])
        for record in records:
            values = {key: value['value'] for key, value in record.items()}
            self._generate_pdf_file(values)


    def _generate_pdf_file(self, values):
        """ Create a PDF file of the target company and embed the QR code image """
        customer_name = f"【{values['顧客コード']}】{values['会社名']}様"
        file_path = os.path.join(self.tmp_dir, f"{customer_name}_利用開始案内通知書.pdf")

        # PDF File Initialization
        cc = canvas.Canvas(file_path, pagesize=portrait(A4), bottomup=False)
        width, height = A4
        cc.setTitle("利用開始案内通知書")
        # Header
        cc.setFont(FONT_NAME, 9)
        cc.drawRightString(width - 10*mm, 10*mm, "{:%Y年%-m月%-d日} 発行".format(datetime.datetime.now()))
        cc.setFont(FONT_NAME, 12)
        cc.drawCentredString(width / 2, 30*mm, f"{customer_name}　利用開始案内通知書")
        # Footer
        cc.setFont(FONT_NAME, 9)
        cc.drawRightString(width - 10*mm, height - 16*mm, "MindWood Inc.")
        # Prepare QR Code generator
        qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M)

        def _insert_table(product_name, field_name):
            """ Inner function for outputting tables """
            url = values[field_name]
            data = [
                ['契約名', product_name],
                ['QRコード', ],
                ['URL', url],
            ]
            styles = [
                ('FONT', (0, 0), (-1, -1), FONT_NAME, 12),
                ('FONT', (1, 2), (1, 2), FONT_NAME, 7),
                ('BOX', (0, 0), (-1, -1), 1, colors.black),
                ('INNERGRID', (0, 0), (-1, -1), 0.25, colors.black),
                ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
                ('BACKGROUND', (0, 0), (0, -1), colors.lightgrey),
            ]
            table = Table(data, (25*mm, 170*mm), (8*mm, 40*mm, 8*mm), TableStyle(styles))
            table.wrapOn(cc, 8*mm, pos*mm)
            table.drawOn(cc, 8*mm, pos*mm)
            qr.clear()
            qr.add_data(url)
            qr.make(fit=True)
            cc.drawInlineImage(qr.make_image(), 100*mm, pos*mm - 10, 100, 100)

        contracts = [
            {'flag': '契約フラグ１', 'name': '利用サービス名１', 'url': '契約１ URL'},
            {'flag': '契約フラグ２', 'name': '利用サービス名２', 'url': '契約２ URL'},
            {'flag': '契約フラグ３', 'name': '利用サービス名３', 'url': '契約３ URL'},
        ]

        pos = 100  # Starting vertical position
        for contract in contracts:
            if values[contract['flag']] is not None:
                _insert_table(values[contract['name']], values[contract['url']])
                pos += 80 # Increment position for next table

        # Save the PDF file
        cc.save()


    def _create_archive(self):
        """ Create zip archive file """
        with zipfile.ZipFile(self.zip_file, 'w', zipfile.ZIP_DEFLATED) as zipf:
            for file in os.listdir(self.tmp_dir):
                file_path = os.path.join(self.tmp_dir, file)
                if os.path.isfile(file_path):
                    zipf.write(file_path, file)
```

kintone のAPIトークンは設定ファイルに記述します。
設定ファイルは`.gitignore`に追加します。

```json:config.json
{
    "api_token": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
}
```

## メソッドの説明
### `__init__`
コンストラクタです。
`config.json`からAPIトークンを取得します。

### `generate`
引数で受け取ったZIPファイルにPDFファイルを生成します。
一時ディレクトリ作成し、privateメソッドを呼びます。

### `_fetch_kintone_record`
kintone API の「複数のレコードを取得する」を使って顧客情報を取得します。
取得できたら、顧客ごとに、次のメソッドに渡してPDFファイルの作成を指示します。

https://cybozu.dev/ja/kintone/docs/rest-api/records/get-records/

### `_generate_pdf_file`
受け取った顧客情報を基にPDFファイルを生成します。
契約フラグをみて、契約中なら、その契約名、URL、URLから生成したQRコードからなる表を作成し、PDFに挿入します。

PDF作成ライブラリ ReportLab の使い方は、Qiitaを始め各サイトで解説されており、情報量は豊富です。
Canvasを作成するときに`bottomup=False`を指定しているので原点は左上になることに注意して下さい。

### `_create_archive`
複数のPDFファイルをZIP形式に圧縮します。

## モジュールの使い方
generateメソッドにZIPファイルを指定するだけです。

### 端末から実行するには

```python
import os
import pdf_qr_generator

zip_file = os.path.join(os.getcwd(), "foo.zip")

pq = pdf_qr_generator.PdfQrGenerator()
pq.generate(zip_file)
```

### Webから実行するには
Flask のファイルを２つ追加します。

```python:app.py
import datetime
import tempfile
from flask import Flask, render_template, request, send_file
import pdf_qr_generator

app = Flask(__name__)

@app.route('/')
def main():
    return render_template("main.html")

@app.route('/submit', methods=['POST'])
def submit():
    if request.form['password'] != "hogehoge":
        return render_template("main.html", message="パスワードが違います")
    
    with tempfile.NamedTemporaryFile(delete=True) as tmpf:
        try:
            pq = pdf_qr_generator.PdfQrGenerator()
            pq.generate(tmpf.name)
            return send_file(
                tmpf.name,
                as_attachment=True,
                download_name="pdf_files-{:%Y%m%d-%H%M%S}.zip".format(datetime.datetime.now()),
                mimetype="application/zip"
            )
        except Exception as e:
            return render_template("main.html", message=f"内部でエラーが発生しました: {e}")

if __name__ == '__main__':
    app.run()
```


```html:main.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>パスワードを入力して下さい</title>
</head>
<body>
    <form action="/qrapp/submit" method="POST">
        <label for="password">パスワード：</label>
        <input type="password" id="password" name="password" required autofocus>
        <button type="submit">ダウンロード</button>
    </form>
    <p style="color:#f00">{{ message }}</p>
</body>
</html>
```

パスワードを入力するだけのシンプルなフォームです。
パスワードが一致していれば、ZIPファイル（複数のPDFファイルを圧縮したもの）がダウンロードされます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/236222/188ab1a5-878d-c1ae-a516-8f55c7e6e4cb.png)