FlaskとHerokuでタイムズカープラス最安プラン選択アプリを作る

# はじめに
「[PythonではじめるHeroku 2018](https://qiita.com/torukashima/items/0d6d00d0186b153d5e45)」で，Herokuのチュートリアルをやりました．

チュートリアルではあらかじめ用意されていたアプリ使ったので，今回は自分で書いたPythonコードを使って，Herokuにデプロイしてみたいと思います．具体的には，「[タイムズカープラスで最安プランを選ぶ](https://qiita.com/torukashima/items/bbf965d2f217c48cff43)」で書いたPythonコードを動かします[^1]．

[^1]: タイムズカープラスについては[公式サイト](https://plus.timescar.jp/)を参照してください

フレームワークは[Flask](http://flask.pocoo.org/)を使うことにします．

### なぜFlask？
Flaskを使ったのは，以下のような思考によるものです．

* 何かフレームワークを使ってみたい
* でもシンプルなやつ，必要最小限で良い
* なんかFlaskってのが良いらしい

### 参考にさせていただいたQiita記事
* 「[Flaskの簡単な使い方](https://qiita.com/zaburo/items/5091041a5afb2a7dffc8)」
* 「[ウェブアプリケーションフレームワーク Flask を使ってみる](https://qiita.com/ynakayama/items/2cc0b1d3cf1a2da612e4)」

### 作業前のローカル環境
* MacOSX 10.11.6
* Python 3.6.3
* HerokuCLI 7.7.10
* Git 2.10.1

# 1. 準備
まずは準備です．ローカル環境を作っていきます．

## 1.1. Pipenvで環境を作る

「[PythonではじめるHeroku 2018](https://qiita.com/torukashima/items/0d6d00d0186b153d5e45)」で学んだ通り，Pipenvを使います．

アプリ名は`tcpopt`(Times Car Plus Optimization)にしておきましょう．

```:terminal
$ mkdir tcpopt
$ cd tcpopt
$ pipenv --three
```
これで`Pipfile`が作成されます．

## 1.2. Flaskインストール

Flaskをインストールします．あと，計算で使うPandasですね．

```:terminal
$ pipenv install flask
$ pipenv install pandas
```
この状態だと，`Pipfile`はこんな感じです．

```:terminal
$ more Pipfile
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"

[packages]
flask = "*"
pandas = "*"

[dev-packages]

[requires]
python_version = "3.6"
```
次はアプリの中身を作っていきます．

# 2. アプリの中身を作る

今回は（JavascriptもCSSも使わずに）フロントページで入力を促し，ボタンクリックで画面遷移して，遷移先で計算結果を表示する，というだけにしておきます．

## 2.1. Modelを作る

「[タイムズカープラスで最安プランを選ぶ](https://qiita.com/torukashima/items/bbf965d2f217c48cff43)」でほぼベタ書きだったコードを，ちょっと整理しておきます．

料金テーブルを保持するクラス`FareTable`と，条件に応じて料金を計算するクラス`FareCalculator`を，1つのファイル`model.py`にまとめます．

```python:model.py
import math
import datetime as dt
import pandas as pd

class FareTable():
    def __init__(self):
        # plan names
        plans = ['short', 'hour6', 'hour12', 'hour24', 'early', 'late', 'double']

        # fare amount
        a = {'base':[0, 4020, 6690, 8230, 2060, 2060, 2580], 
             'extra':[206, 206, 206, 206, 206, 206, 206],
             'distance':[0, 0, 16, 16, 16, 16, 16]}
        amount = pd.DataFrame(data=a, index=plans)

        # fare condition
        c = {'type':['length', 'length', 'length', 'length', 'time', 'time', 'time'],
             'length':[0, 6, 12, 24, 6, 9, 15],
             'start':['', '', '', '', 18, 0, 18],
             'by':['', '', '', '', 24, 9, 24],
             'max':[72, 6, 6, 6, 6, 6, 6]}
        condition = pd.DataFrame(data=c, index=plans)
        self.__whole = pd.concat([amount, condition], axis=1)
    
    @property
    def whole(self):
        return self.__whole
        
class FareCalculator():
    def __init__(self, start, end, distance):
        self.s = start
        self.e = end
        self.d = distance
        self.__f = FareTable()
    
    @property
    def fare_table(self):
        return self.__f.whole

    def __calc_extra(self, plan):
        fare = self.__f.whole
        if fare.at[plan, 'type'] == 'length':
            extra = self.e - self.s - dt.timedelta(
                hours=int(fare.at[plan, 'length']))
        elif fare.at[plan, 'type'] == 'time':
            plan_start = dt.datetime(year=self.s.year, 
                                     month=self.s.month, 
                                     day=self.s.day, 
                                     hour=fare.at[plan, 'start'])
            plan_end = plan_start + dt.timedelta(
                hours=int(fare.at[plan, 'length']))
            extra = self.e - plan_end
        else:
            extra = math.inf
        return max(0, math.ceil((extra.total_seconds()/3600)*4))

    def calc_fare(self, plan):
        fare = self.__f.whole
        if (fare.at[plan, 'type'] == 'time') and (
                self.s.hour < fare.at[plan, 'start'] or
                self.s.hour >= fare.at[plan, 'by']):
            return math.inf
        else:
            extra_qnum = self.__calc_extra(plan)
        if extra_qnum > fare.at[plan, 'max']*4:
            return math.inf
        else:
            return (fare.at[plan, 'base'] + 
                    fare.at[plan, 'extra'] * extra_qnum + 
                    fare.at[plan, 'distance'] * self.d)
```
## 2.2. フロントページを作る
見た目は考えずに作ります．`template`ディレクトリの中に入れます．

```html:template/index.html
<!doctype html>
<html>
  <head>
    <title>TCPOPT: Times Car Plus OPTimization</title>
  </head>
  <body>
    <h1>TCPOPT: Times Car Plus OPTimization</h1>
    <form method="post" action="/result">
      from:
      <input type="datetime-local" value="2018-08-26T19:30" name="start" /><br />
      to:
      <input type="datetime-local" value="2018-08-27T08:30" name="end" /><br />
      distance:
      <input type="number" value="200" min="0" max="500" step="1" name="distance" />km<br />
      <button>Calculate</button>
    </form>
  </body>
</html>
```
## 2.3. Viewを作る

とりあえず書いていきます．

datetime-localをパースする部分は[こちら](https://stackoverflow.com/questions/33224068/how-convert-datetime-local-to-datetime-in-python)を参考にしました．きっともっと良いやり方があるのでしょうが，今は細かいことは気にしません．

```python:view.py
from flask import Flask, render_template, request
import datetime as dt
import pandas as pd
from model import FareCalculator, FareTable

app = Flask(__name__)

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

@app.route('/result', methods=['POST'])
def result():
    # need some improvement
    start = request.form['start']
    start = start.replace('T', '-').replace(':', '-').split('-')
    start = [int(v) for v in start]
    start = dt.datetime(*start)
    end = request.form['end']
    end = end.replace('T', '-').replace(':', '-').split('-')
    end = [int(v) for v in end]
    end = dt.datetime(*end)

    distance =(request.form['distance'])
    calculator = FareCalculator(start, end, distance)

    plans = calculator.fare_table.index
    result = pd.DataFrame(columns=[], index=plans)
    for plan in plans:
        result.at[plan, 'amount'] = calculator.calc_fare(plan)
    
    return ('Plan:' + str(result.amount.idxmin()) 
            + ', Amount: ' + str(result.amount.min())
           )

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

## 2.4. アプリを起動する

ここまででアプリは動くようになりました．ローカルで起動してみます．

```:terminal
$ pipenv shell
$ python view.py
```
この状態で，[http://localhost:5000/](http://localhost:5000/)をWEBブラウザで開くと，`index.html`が表示されます．

![20180826_tcpopt_index.png](https://qiita-image-store.s3.amazonaws.com/0/157373/bc162a35-d8b7-bc74-1fa7-a5d13aeabb62.png)

そのままcalculateボタンを押すと，[http://localhost:5000/result](http://localhost:5000/result)にページ遷移し，計算結果

Plan:double, Amount: 5780.0

が表示されます．

また，利用開始時刻，返却予定時刻，距離を変更してボタンを押すと，計算結果も変わります．

ターミナルで`Ctrl+C`を押せば，サーバが停止します．

# 3. Herokuにデプロイする

最後にHerokuで動かします．

## 3.1. ローカルで動かす

`gunicorn`をインストールします．

```:terminal
$ pipenv install gunicorn
```

次に，`Procfile`を作ります．

```text:Procfile
web: gunicorn view:app
```
この時点で，ディレクトリ構成はこんな感じになっています．

```:terminal
$ tree -A
.
├── Pipfile
├── Pipfile.lock
├── Procfile
├── model.py
├── templates
│   └── index.html
└── view.py
```

Herokuを使ってローカルで動かします．

```:terminal
$ heroku local web
```

[http://localhost:5000/](http://localhost:5000/)をWEBブラウザで開くと，さっきFlaskで動かしたのと同じように，`index.html`が表示されます．

## 3.2. Heroku上にデプロイする

まずはHerokuにログインして，アプリを`create`します．名前が重複してなければできるはず．

```:terminal
$ heroku login
$ heroku create tcpopt
```

Gitのリポジトリを作って`push`します．

```:terminal
$ git init
$ git add .
$ git commit -m "first commit"
$ git push heroku master
$ heroku open
```

これで，作成したWEBアプリ「[TCPOPT: Times Car Plus Optimization](https://tcpopt.herokuapp.com/)」が動いているのが確認できます．

（開発中のものなので，止めたり消したりすることがあります．あと当然ながら計算結果の正確性は保証しません．テスト全くしてないし．）

# おわりに
これでタイムズカープラスの最安プランがWEBで計算できるようになりました．

ただ，中身は粗だらけなので，これから改良していく予定です．