1
1

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

Last updated at Posted at 2024-09-16

やりたいこと

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

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

image.png

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

サーバ構成

  • Ubuntu 20.04 (Amazon EC2)
  • Apache 2.4
  • Python 3.8

使用したオープンソース

  • reportlab - PDFファイルを生成するため
  • qrcode - QRコードを生成するため
  • 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アプリケーションフレームワークです。

pip3 install flask, reportlab, qrcode

環境設定

Flask の設定です。

mkdir /var/www/flask
chown -R www-data:www-data flask
/var/www/flask/qrapp/wsgi.py
import sys

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

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

/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 と接続するための設定をします。
Ubuntu 20.04 の場合、locale=ja_JP.UTF-8 も必要です。

/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/qrappHello World! と表示されたらOK

systemctl restart apache2

実行してみよう

まず、完動するコードを先に示します。

qrpdfgen.py
""" Generate a PDF file to guide companies to the product URL and QR code """
__author__ = "MindWood"
__version__ = "1.0.0"

import sys
import os
import datetime
import glob
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


def work_directory_init():
    """ Initialize the working directory """
    os.makedirs(pdf_directory, exist_ok=True)
    pdf_files = glob.glob(os.path.join(pdf_directory, "*.pdf"))

    for pdf_file in pdf_files:
        try:
            os.remove(pdf_file)
        except Exception as e:
            print(f"Error deleting {pdf_file}: {e}")
            sys.exit(1)


def get_kintone_record():
    """ Use kintone api to get properties of all registered companies """
    headers = {
        "X-Cybozu-API-Token": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
    }
    params = {
        "app": 1234,
    }
    response = requests.get("https://foo.cybozu.com/k/guest/99/v1/records.json", headers=headers, params=params)
    if response.status_code == 200:
        records = response.json().get('records', [])
        for record in records:
            values = {key: value["value"] for key, value in record.items()}
            generate_pdf_file(values)
    else:
        print(f"Error response {response.status_code}: {response.text}")
        sys.exit(2)


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

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

    def _insert_table(product_name, field_name):
        """ Inner function for 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, height - (100 + pos)*mm)
        table.drawOn(cc, 8*mm, height - (100 + pos)*mm)
        qr.clear()
        qr.add_data(url)
        qr.make(fit=True)
        cc.drawInlineImage(qr.make_image(), 100*mm, height - (90 + pos)*mm, 100, 100)

    pos = 0  # Vertical position
    if values["契約フラグ1"] is not None:
        _insert_table("利用サービス名1", "契約1 URL")
        pos += 80

    if values["契約フラグ2"] is not None:
        _insert_table("利用サービス名2", "契約2 URL")
        pos += 80

    if values["契約フラグ3"] is not None:
        _insert_table("利用サービス名3", "契約3 URL")

    # Save file
    cc.save()


def create_archive():
    """ Create zip archive file """
    fd, temp_path = tempfile.mkstemp(suffix='.zip')
    try:
        with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as zipf:
            for file in os.listdir(pdf_directory):
                file_path = os.path.join(pdf_directory, file)
                if os.path.isfile(file_path):
                    zipf.write(file_path, file)
    finally:
        os.close(fd)
    return temp_path


def main():
    work_directory_init()
    get_kintone_record()
    return create_archive()


pdf_directory = os.path.join(os.getcwd(), "pdf_files")

if __name__ == '__main__':
    zip_file_path = main()
    os.remove(zip_file_path)

各関数の説明

work_directory_init

PDFファイルを出力するディレクトリが無ければ作成します。
古いPDFファイルがあれば削除します。

get_kintone_record

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

generate_pdf_file

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

PDF作成ライブラリ ReportLab の使い方は、Qiitaを始め、各サイトで解説されており、情報量は豊富です。
ひとつ加えるなら、座標の原点が左下なので注意。
あとで知ったことですが、Canvasを作成するときにbottomup=Falseとすると原点が左上になるようです。この方が我々には馴染みますね。

create_archive

複数のPDFファイルをZIP形式に圧縮し、一時ファイルに保存、一時ファイル名を返却します。

実行手順

端末からコマンドで実行するには

python3 qrpdfgen.py

Web経由で実行するには

Flask のファイルを2つ追加します。

app.py
import datetime
from flask import Flask, render_template, request, send_file
import qrpdfgen

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="パスワードが違います")
    zip_file_path = qrpdfgen.main()
    if zip_file_path is None:
        return render_template('main.html', message="内部でエラーが発生しました")
    return send_file(
        zip_file_path,
        as_attachment=True,
        download_name='pdf_files-{:%Y%m%d-%H%M%S}.zip'.format(datetime.datetime.now()),
        mimetype='application/zip'
    )

if __name__ == '__main__':
    app.run()
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

課題

ちょっとした便利ツールという位置づけで作ったので、商用で使うには色々と課題があります。

APIトークンをハードコーディングしている

次のような対策はするべきでしょう。

  • 環境変数にトークンを保存し、コードでそれを参照する。
  • config.jsonのような設定ファイルを作成し、その中にトークンを保存する。
    このファイルは、バージョン管理(Gitなど)で追跡されないように.gitignoreに追加します。
  • シークレットを安全に保管するクラウドサービス(AWS Secrets Manager とか Azure Key Vault など)を使用してトークンを保存し、コードから取得する。

複数人が同時に起動した場合の考慮が無い

PDFファイルを出力するディレクトリが固定なので、Webアプリケーションから実行したときに他者と競合します。
組み込みモジュールtempfileTemporaryDirectory()に置き換えましょう。
作成された一時ディレクトリは閉じた時点で自動削除してくれます。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1