Flaskで自動的に化学小テストを生成するアプリを作った話

どうも、小田急電鉄株式会社初のIT開発エンジニア、Xuです。

実は仲がいい隣人が化学の教師をやっていて、
「毎回授業で高校一年生に小テストを出してるんだけど、出題するのがめんどくさい」
と言われ、彗星のごとく現れた自動化の天才(自称)として見逃せない事態だと認知し、
重たいVBAでテスト問題を生成していた隣人を助けるべく始めたプロジェクトです。
地味に初めてflaskアプリを公開するので色々とコード汚いのはご愛嬌ということで( ´∀｀ )

## 環境
-AWS EC2(t2.micro無料枠)
-Ubuntu 20.04
-Apache2
-Python3.8


## 前準備
考え方としては
(xg/xmol/xL)の(物質名)の(体積/質量/物質量)的な文を自動的にPDFに生成していき、
出来上がった問題がいい感じであればファイルをダウンロードするというシンプルなもの。

ディレクトリ構成
/var/www/html/
|
|---app
|　　|
|　　|--flask_app
|　　|
|　　|--app.wsgi
|
|---static
　　|
　　|--template.pdf
　　|
　　|--ipag.ttc

staticは他appと共用のため構造がちょっと汚い^^;

## ライブラリインストール
```
sudo apt-get install apache2
sudo apt -y install python3-pip
sudo pip install mod_wsgi
sudo pip install flask
sudo pip install PyPDF2
sudo pip install reportlab
```

Python3.8環境でfitzをインストールしようとすると以下のエラーが出るため、
```bash
File "/home/adam/venvs/p3/lib/python3.8/site-packages/starlette/staticfiles.py", line 55, in __init__
	raise RuntimeError(f"Directory '{directory}' does not exist")
```
代わりに
```bash 
sudo pip install PyMuPDF
```
を使う、すでにインストールしていた場合はアンインストールしましょう。

## とりあえず実行可能なflaskアプリを作って、WGSIでデーモン化してみる
```Python　/var/www/html/app/flask_app/app.py
from flask import Flask
app = Flask(__name__)

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

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

```Python /var/www/html/app/flask_app/app.wsgi
import sys

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

## path確認
```bash
python -c "import sys; print(sys.path)"
```

そのうちmod_wsgi-py38.cpython-38-x86_64-linux-gnu.soが入ってるディレクトリを見つけ出し、
/etc/apache2/sites-available/000-default.confに追加：
```
LoadModule wsgi_module /usr/local/lib/python3.8/dist-packages/mod_wsgi/server/mod_wsgi-py38.cpython-38-x86_64-linux-gnu.so
WSGIDaemonProcess hello user=ubuntu
WSGIScriptAlias /app /var/www/html/app/app.wsgi
```
本来はサイトごとに.confファイルを書くべきだが、今回はほかにブラウザーからアクセスするアプリがないので、直書きしました。

さて、ブラウザーで確認してみようかとすると、

早速、ここでエラーその1
### RuntimeError: The 'apxs' command appears not to be installed or is not executable. Please check the list of prerequisites in the documentation for this package and install any missing Apache httpd server packages.
解決方法：
```bash
sudo apt install apache2-dev
```

無事helloworldが出力されたところで,本題に移る。

フロントは適当にtemplatesディレクトリにhtmlファイルを直書きで、Ajax通信で生成開始クリック時の処理をPythonに投げる
## Ajax通信
```JavaScript
function sleep(waitSec, callbackFunc) {
    // 経過時間（秒）
    var spanedSec = 0;
    // 1秒間隔で無名関数を実行
    var id = setInterval(function () {
        spanedSec++;
        // 経過時間 >= 待機時間の場合、待機終了。
        if (spanedSec >= waitSec) {
            // タイマー停止
            clearInterval(id);
            // 完了時、コールバック関数を実行
            if (callbackFunc) callbackFunc();
        }
    }, 1000);
}

var btn  = document.getElementById('create');
var loader = document.getElementById('loader');

btn.addEventListener('click', function() {
    btn.classList.add('hide');
    loader.classList.remove('hide');
    var post = $.ajax({
        url: '/app/generat',
        type: 'POST',
        data: JSON.stringify({'key': 'chemistry'}),
        async: false,
        contentType: "application/json"
    }).done(function(response){
        sleep(5, function () {
            console.log(response);
            var file_name = '/static/test_' + response + '.pdf';
            document.getElementById('result_pdf').src = file_name;
            loader.classList.add('hide');
            btn.classList.remove('hide');
        });
    }).fail(function(){
        alert('error!');
    }); 
});
```

## flask本体
いい感じに生成する物質の物質量で割り切れるようにランダム数を操作する。
高校化学嫌いだったので今はもうほとんど覚えてない(なお卒業研究は材料系の研究をしていた模様)

```Python app.py
import os
import fitz
import random
import datetime
from decimal import Decimal
from PyPDF2 import PdfFileWriter, PdfFileReader
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import A4, portrait
from reportlab.lib.units import inch, mm, cm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from flask import Flask, render_template
from flask import request

def random_num():
    while True:
        num = random.random()*10    
        if num > 1.1:
            num -= 1
            break

    return round(Decimal(num), 1)

app = Flask(__name__)

@app.route('/chemistry')
def index():
    return render_template('chemistry.html')

@app.route('/generat', methods=["POST"])
def generate():
    now = datetime.datetime.now(datetime.timezone(datetime.timedelta(hours=9)))
    dt_now = str(now.strftime('%Y%m%d%H%M%S'))
    content = []
    template_file     = '/var/www/html/static/template.pdf'
    tmp_file          = '/var/www/html/static/test_tmp.pdf'
    output_file       = '/var/www/html/static/test_'+ dt_now + '.pdf'
    ttc_file          = '/var/www/html/static/ipag.ttc'
    w, h              = portrait(A4)
    cv                = canvas.Canvas(tmp_file, pagesize=(w, h))
    pdfmetrics.registerFont(TTFont('ipag', ttc_file))
    cv.setFillColorRGB(0, 0, 0)

    for i in range(1, 11):
        sub_vol = random_num()
        vol = random_num() * Decimal('22.4')
        mass = random_num() * 71

        conn = ['6.0x10 個の', str(vol)+'Lの', str(mass)+'gの', str(sub_vol)+'molの']
        name = ['水素H2', '炭素C', '窒素N2', '酸素O2', 'フッ素F2', 'ナトリウムNa', 'マグネシウムMg', 'アルミニウムAl', '硫黄S', '塩素Cl', 'カリウムK', 'カルシウムCa', '鉛Pb', '銀Ag', 'ヨウ素I2', 'メタンCH4', 'プロパンC3H8', 'エタノールC2H5OH', '二酸化炭素CO2', '水H2O', 'アンモニアNH3', 'ヘリウムHe']
        cel_vol = [2, 12, 28, 32, 38, 23, 24, 27, 32, 71, 39, 40, 207, 108, 254, 16, 44, 46, 44, 18, 17, 4]
        ques = ['分子の数', '体積', '質量', '物質量']

        rdm_num_1 = random.randint(0, len(conn)-1)
        rdm_num_2 = random.randint(0, len(name)-1)
        rdm_num_3 = random.randint(0, len(ques)-1)

        while rdm_num_1 == rdm_num_3:
            rdm_num_1 = random.randint(0, len(conn)-1)
            rdm_num_3 = random.randint(0, len(ques)-1)
            
        content.append(conn[rdm_num_1] + name[rdm_num_2] + 'の' + ques[rdm_num_3])
```

いい感じの間隔で文字を配置する、これマジで職人技だと思う。
```Python
    for i in range(0, 10):
        font_size     = 12
        cv.setFont('ipag', font_size)
        cv.drawString(35*mm, h-(52.5+i*20)*mm, content[i])
        if '6.0x10' in content[i]:
            font_size = 6
            cv.setFont('ipag', font_size)
            cv.drawString(47.7*mm, h-(50.5+i*20)*mm, '23')

    cv.showPage()
    cv.save()

    template_pdf      = PdfFileReader(template_file)
    template_page     = template_pdf.getPage(0)
    tmp_pdf           = PdfFileReader(tmp_file)
    template_page.mergePage(tmp_pdf.getPage(0))
    output            = PdfFileWriter()
    output.addPage(template_page)
    with open(output_file, 'wb') as fp:
      output.write(fp)

    os.remove(tmp_file)
    return dt_now
    
if __name__ == '__main__':
    app.run()
```

修正を加えた後は
```bash
sudo systemctl restart apache2
```
を実行しないと変更が反映されない。

さあーてそろそろ動くだろうと思った矢先に

## エラー続出！

エラーその２
### apache2 wsgi ImportError: No module named 'xxx'
どうやら一部pathが違うところに通ってしまったライブラリがあるらしい、
```bash
sudo pip install xxx
```
または同じ仮想環境に入れるように同一しましょう。

### /requests/__init__.py:89: RequestsDependencyWarning: urllib3 (x.x.x) or chardet (x.x.x) doesn't match a supported version!
https://teratail.com/questions/169266
バージョンを確認
https://pypi.org/project/chardet/#files
https://pypi.org/project/urllib3/#files
```bash
pip install chardet -U
pip install urllib3 -U
sudo pip install chardet -U
sudo pip install urllib3 -U
```
とりあえず最新にすることで解決できる。

### flask and passenger "TypeError: 'module' object is not callable"
app.wsgiを見直しましょう、大抵どっちかが抜けてます
```Python
from app import app as application
```

## これでとりあえず完成！
なんのデコレーションもない雑な感じだけどｗ
![chemistry_result.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2466211/82ef3bcf-12ac-cd88-4ec7-dd6142f5a2a6.png)

あとはロードアニメメーションでもつけてわかりやすくして完成です、それではまた次回お会いしましょう～