https://a.storyblok.com/f/270183/35111/dfc944b669/manage-queues_1200x600.png

PythonとFlaskを使ってSMSでキューを管理する

最終更新日 May 18, 2021

所要時間:9 分

多くの人間的な体験がテクノロジーの応用によって改善される可能性がある一方で、明らかに現代的なソリューションの機が熟しているものがある: 行列に並ぶ.運転免許証の更新をするにしても、人気のブランチ・スポットで席を確保するにしても、見知らぬ人たちと待合室に詰め込まれて過ごす時間は、できれば避けたいものだろう。幸いなことに、携帯電話の普及により、テキストやウェブベースの新しい待ち行列システムが一般的になりつつある。

このチュートリアルでは Pythonフラスコフレームワークを使って簡単なSMSキュー管理システムを構築する方法を紹介します。主なコンポーネントは3つあります:

  • テキストメッセージに応答し、適切なアクションを取るためのバックエンド

  • ウェブ経由またはキオスク端末で表示可能なステータスページ

  • 管理ページでは、列に並んでいる人々をリストアップし、通知したり削除したりすることができる。

物事をシンプルにするため、このアプリケーションは最も基本的な機能しか扱わないが、このアプリケーションが提供する骨組みを使えば、ニーズに合った堅牢なシステムを簡単に構築できるはずだ。

前提条件

セットアップ

開始するには、プロジェクト用の新しいディレクトリを作成し、そこに移動する:

mkdir sms-queue-notify cd sms-queue-notify

最終的なコードから始めたい場合は サンプルプロジェクトをクローンしてください:

git clone https://github.com/nexmo-community/sms-queue-notify.git cd sms-queue-notify

プロジェクト・ディレクトリに入ったら、仮想環境を作成してアクティブにします:

python3 -m venv venv source venv/bin/activate

仮想環境は、プロジェクトの依存関係を管理し、分離するのに役立つ。このプロジェクトに必要な依存関係をインストールするには requirements.txtファイルが必要です。サンプル・レポをクローンしたのであれば、すでに準備はできていますが、ゼロからビルドするのであれば、ファイルは以下のようにする必要があります:

Flask==1.1.1
Flask-SQLAlchemy==2.4.1
nexmo==2.4.0

これらの依存関係をインストールするには、プロジェクト・ディレクトリで以下を実行する:

pip install -r requirements.txt

データベースの初期化

アプリケーションを正しく実行する前に、行列に並んでいる人の情報を保存するデータベースをセットアップする必要があります。このプロジェクトで必要なデータストレージは比較的単純なので、Pythonの組み込みデータベースであるSQLiteを使います。データベースの管理には Flask-SQLAlchemyこれはデータベースの上にレイヤーを提供するので、SQL を書く代わりに簡単な関数でクエリを作ることができます。

まだサンプル・レポからコードをダウンロードしていない場合は、次の名前のファイルを新規に作成してください。 main.pyという名前の新しいファイルを作成してください:

from flask import Flask, render_template, request, Response
from flask_sqlalchemy import SQLAlchemy

db_path = "sqlite:///queue.db"

app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = db_path

db = SQLAlchemy(app)

class User(db.Model):
    phone_number = db.Column(db.String, unique=True, nullable=False, primary_key=True)
    notified = db.Column(db.Integer)
    join_time = db.Column(db.DateTime)
    wait_time = db.Column(db.Integer)

if __name__ == '__main__':
    app.run(debug=True, threaded=True)

このスクリプトは、Flask アプリケーションのセットアップと、データベースの基本的な設定(データベースの名前と場所の定義、データベース内のカラムの名前とデータ型の定義など)を行います。このスクリプトかサンプル main.pyを入手したら、対話モードで Python を起動します:

python

次に以下のコマンドを実行してデータベースをセットアップする:

>>> from main import db >>> db.create_all() >>> quit()

ngrokとNexmoの構成

データベースの準備ができたので、Nexmoを使ってテキストメッセージを送受信するための基本的なセットアップを行う必要があります。最初のステップとして、ngrokがインストールされ、ポート5000で実行されていることを確認してください:

ngrok http 5000

ngrokを実行すると、ローカルサーバーへの転送に使用するURLが表示されます。すぐに使うことになるので、このURLをメモしておいてください:

An example of an ngrok instance running

Nexmoダッシュボードのメインページで、キーとシークレットを探します:

Screenshot of the Nexmo dashboard

これらをコピーし、次のようにファイルの先頭付近に置く。 main.pyファイルの先頭付近に次のように記述する:

NEXMO_KEY = "<Your Nexmo Key>"
NEXMO_SECRET = "<Your Nexmo Secret>"

そして、次の行を追加する:

client = nexmo.Client(key=NEXMO_KEY, secret=NEXMO_SECRET)

ダッシュボードに戻り、次の場所に移動する。 Numbers-> あなたのNumbers(まだ番号をお持ちでない方は ナンバーズ-> Numbersを購入するに進みます)。自分の番号の上にカーソルを合わせると コピーボタンをクリックする。これをクリックし、Numbersに貼り付けます。 main.pyのように貼り付ける:

NEXMO_NUMBER = "<Your Nexmo Number>"

ここにいる間に、「管理」の下にある歯車のアイコンをクリックしてください。 管理をクリックしてください:

A screenshot of a page showing your rented numbers

には インバウンド・ウェブフックURLフィールドに、先ほど収集したngrok URLを使用して、次のように入力します:

<Your ngrok URL>/webhooks/inbound-sms

Defining webhook urls

この設定を保存する。

サンプル・レポから作業している場合、アプリケーションを実行するために必要なものはすべて揃っています。ターミナルに以下を入力し、開発サーバーを起動してください:

python main.py

ブラウザで、ngrok URLに移動してステータスビューを見るか、次の場所に移動して管理ビューを見る。 <ngrok url>/listにアクセスして管理ビューを見る。次に、設定した番号に「Hi」とテキストを送信して、自分自身をリストに追加してください!

ゼロから作る人のために、バックエンドロジックの作成に移ろう。

バックエンド

このアプリケーションのバックエンドの構造は比較的シンプルです。4つのルートを作成します-2つはフロントエンドのビューに関連し、1つは受信SMSメッセージを受信するためのWebhook、そして1つは サーバーが送信したイベント.ルートに加えて、受信したメッセージとその他のユーザー入力に基づいてアクションを実行する一連の関数が必要です。最後に、データベースクエリを管理し、テキストをフォーマットするヘルパー関数がいくつかあります。

まずはビューのルートから。以下を main.pyを追加します。 if __name__ == '__main__':

@app.route('/')
def index():
    return render_template('index.html', length=query_length(), number=phone_format(NEXMO_NUMBER))

@app.route('/list', methods=('GET', 'POST'))
def list():
    if request.method == 'POST':
        if 'notify' in request.form:
            notify(request.form['notify'])
        elif 'remove' in request.form:
            remove(request.form['remove'])
        elif 'arrived' in request.form:
            remove(request.form['arrived'])
    users = query_users()
    return render_template('list.html', users=users)

ルートは簡単だ。 indexルートは簡単で、誰かがサイトにアクセスすると、(まだ作成されていない)ページの内容が表示される。 index.htmlページ(まだ作成されていない)の内容が表示され、そこには回線の長さと電話番号に関する情報が含まれる(近日中に書く関数によって助けられる)。

ルート listルートにはもう少し情報があります。ページが表示されると(GETリクエスト)、訪問者は list.htmlのコンテンツが表示されます。ページ上のフォームを通してこのルートにPOSTリクエストがなされた場合、リクエストからの情報はどのボタンが押されたかを決定するために使われます。この notify, removequery_users関数は後で定義します。

これらのルートが定義できたので、次はWebhookルートを追加します:

@app.route('/webhooks/inbound-sms', methods=('GET', 'POST'))
def inbound_sms():
    if request.is_json:
        message = request.get(json())
    else:
        message = dict(request.form) or dict(request.args)
    num = message['msisdn']
    text = message['text'].lower()
    map = {
        "hi": add,
        "cancel": remove,
        "status": status,
        "help": help
    }
    action = map.get(text)
    if action:
        action(num)
    else:
        send(num, "Could not understand. Please try again")
    return ('', 204)

このルートは先ほどの設定ステップで見たことがあるかもしれません。NexmoがこのWebhookで設定した番号にSMSメッセージを受信すると、メッセージの内容と送信者の情報を含むリクエストオブジェクトを取得します。今回の目的では、送信者の電話番号とメッセージのテキストを取り出し、テキストを適切なアクション関数にマッピングします。メッセージを解析できない場合は、送信者にその旨を伝え、再試行できるようにします。

最終的なルートを作成する前に、ヘルパー関数とアクション関数を定義しましょう。ルート定義の後の main.pyに記述します:

def phone_format(num):                                                
    return format(int(num[:-1]), ",").replace(",", "-") + num[-1]

def query_length():
    return User.query.filter(User.notified == 0).count()

def query_users():
    users_waiting = []
    users_notified = []
    for result in User.query.all():
        if result.notified == 0:
            time_diff = datetime.now() - result.join_time
            wait_time = divmod(time_diff.seconds, 60)[0]
            user = {"phone_number": str(result.phone_number), "wait_time": wait_time}
            users_waiting.append(user)
        else:
            wait_time = result.wait_time
            user = {"phone_number": str(result.phone_number), "wait_time": wait_time}
            users_notified.append(user)
    users = {"waiting": users_waiting, "notified": users_notified}
    return users

def send(num, text):    
    response = client.send_message({'from': NEXMO_NUMBER, 'to': num, 'text': text})
    response = response['messages'][0]
    if response['status'] == '0':
        print('Sent message', response['message-id'])
    else:
        print('Error:', response['error-text'])
    return

これらのヘルパー関数は以下のように動作する:

  • phone_format:設定した電話番号を表示用にハイフンで分割します。

  • query_length:データベースに照会して、何人並んでいるかを確認する。

  • query_users:全ユーザーのデータベースを照会し、その結果を、順番待ちのユーザーとすでに通知されたユーザーとが別々にグループ化されるようにフォーマットする。

  • send:指定された番号とテキストでSMSメッセージを送信します。

次に、これらのアクション関数を追加する:

def add(num):
    if User.query.get(num):
        send(num, "Hello again!")
        status(num)
    else:
        user = User(phone_number=num, notified=0, join_time=datetime.now())
        db.session.add(user)
        db.session.commit()
        send(num, "You've been added to the list")
        help(num)
    return

def remove(num):
    user = User.query.get(num)
    if user:
        db.session.delete(user)
        db.session.commit()
        send(num, "You've been removed from the list")
    else:
        print("User not found")
    return

def notify(num):
    user = User.query.get(num)
    if user.notified == 0:
        send(num, "Your turn")
        user.notified = 1
        time_diff = datetime.now() - user.join_time
        user.wait_time = divmod(time_diff.seconds, 60)[0]
        db.session.commit()
    else:
        print("User already notified")
    return
    
def status(num):
    user = User.query.get(num)
    if not user:
        send(num, "Not in line")
    elif user.notified == 1:
        send(num, "Notified")
    else:
        users = query_users()
        users_sorted = sorted(users["waiting"], key = lambda i: i['wait_time'], reverse = True)   
        i = 0
        while i < len(users_sorted):
            if users_sorted[i]["phone_number"] == num:
                i += 1
                break
            i += 1
        send(num, "Number " + str(i) + " of " + str(len(users_sorted)) + " in line")
    return

def help(num):
    send(num, "For updates, text 'status'nTo remove yourself from the list, text 'cancel'")
    return

要約すると

  • add:Hi'が送られると新しいユーザーを追加する。すでに並んでいる場合は、そのユーザーのステータスも返す。

  • remove:リストからユーザーを削除します。管理ページのボタン、またはユーザーが「cancel」と入力した場合に実行されます。

  • notify:ユーザーに自分の番であることを知らせる。管理ページのボタンからトリガーされる。また、そのユーザーの合計待ち時間を計算し、データベースに保存する。

  • status:どの列に並んでいるかを表示します。

  • help:ユーザーが送信できるコマンドに関する基本情報を提供します。

これらのルートと関数を定義することで、バックエンドに必要なものはほぼ揃いました。最後の4番目のルートは、サーバーからのメッセージに基づいてビューを動的に更新する方法を提供します:

@app.route("/stream")
def stream():
    def eventStream():
        line_length = query_length()
        yield "data: {}nn".format(json.dumps(query_users()))
        while True:
            new_line_length = query_length()
            if new_line_length != line_length:
                line_length = new_line_length
                yield "data: {}nn".format(json.dumps(query_users()))
            time.sleep(1)
    return Response(eventStream(), mimetype="text/event-stream")

このルートは、これまでのルートとは機能が異なる。最初にクライアントから呼び出された後、サーバーがシャットダウンするまでこの関数は実行され続ける。これにより、クライアントとの接続を維持し、行列の人数が変わるたびに更新をプッシュすることができる。

これでバックエンドの部品がすべて揃ったので、次はビューを作成する番だ!

フロントエンド

作成するビューは2つあります。1つはステータスページ用、もう1つは管理ページ用です。まず、新しいディレクトリを作成します、 templatesという新しいディレクトリを作成し、その中に新しいファイルを作成します、 index.html.そのファイルに次のように記述します:

<!DOCTYPE html>
<html>

<head>
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css" integrity="sha384-HSMxcRTRxnN+Bdg0JdbxYKrThecOKuH5zCYotlSAcp1+c8xmyTe9GYg1l9a69psu" crossorigin="anonymous">
</head>

<body>
  <div class="container-fluid center-block text-center">
    <div class="row">
      <div class="col-lg-12">   
        <p id="line_length"  style="font-size: 20vh">{{ length }}</p>
        <p style="font-size: 5vh">in line</p>
      </div>
    <div class="row">
      <div class="col-lg-12" style="height: 20vh">
    </div>
    <div class="row">
      <div class="col-lg-12">   
        <p class="lead" style="font-size: 7vh">Text <b>Hi</b> to <b>{{ number }}</b> to be added</p> 
      </div>
    </div>
  </div>  
</body>
<script>
    var targetContainer = document.getElementById("line_length");
    var eventSource = new EventSource("/stream");
    eventSource.onmessage = function(e) {
      var users = JSON.parse(e.data)
        targetContainer.innerHTML = users.waiting.length;
    };
</script>
<noscript>
    <meta http-equiv="refresh" content="30">
</noscript>

</html>

ここで注意すべき点がいくつかある。まず、Bootstrapを使って基本的なスタイリングを行っています。Bootstrapの使い方については、数週間前の記事 をご覧ください。.次に {{ length }}そして {{ number }}フィールドがあり、テンプレートがレンダリングされるときにバックエンドから入力を受け取ります。最後に、最後の短いJavascriptのスニペットを見てください。 streamエンドポイントに接続し、サーバーから送られたイベントを処理し、動的に並んでいるユーザーの数を更新します。

また、短い noscriptセクションがあり、これはJavascriptが無効になっている場合、30秒ごとにページを更新するように設定されています。このアプリケーションの設定方法は、Javascriptなしでも正しく機能しますが、更新が瞬時に行われないだけです。

管理ページ用に、新しいファイル list.htmlを作成し、以下を含める:

<!DOCTYPE html>
<html>
    <head>
        <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css" integrity="sha384-HSMxcRTRxnN+Bdg0JdbxYKrThecOKuH5zCYotlSAcp1+c8xmyTe9GYg1l9a69psu" crossorigin="anonymous">
    </head>

    <body>
        <form method="POST" class="center-block text-center" style="width: fit-content">
            <h3>In Line</h3>
            <table id="notified" class="table table-condensed">
            {% for user in users.waiting %}
                <tr>
                    <td>{{ user.phone_number }}</td>
                    <td><button name="notify" value="{{ user.phone_number }}" class="btn btn-primary btn-xs" type="submit">Notify</button></td>
                    <td><button name="remove" value="{{ user.phone_number }}" class="btn btn-default btn-xs" type="submit">Remove</button></td>
                </tr>
            {% endfor %}
            </table>
            <h3>Notified</h3>
            <table class="table table-condensed">
            {% for user in users.notified %}
                <tr>
                    <td>{{ user.phone_number }}</td>
                    <td><button name="arrived" value="{{ user.phone_number }}" class="btn btn-primary btn-xs" type="submit">Arrived</button></td>
                </tr>
            {% endfor %}
            </table>
        </form>
    </body>

    <script>
        var targetContainer = document.getElementById("notified");
        var eventSource = new EventSource("/stream");
        eventSource.onmessage = function(e) {
            var user;
            var users = JSON.parse(e.data);
            users = users.waiting.sort((a, b) => (a.wait_time < b.wait_time) ? 1 : -1)
            var user_table = '';
            for (user of users){
                user_table = user_table + '<tr>'
                + '<td>' + user.phone_number + '</td>'
                + '<td><button name="notify" value="' + user.phone_number + '" class="btn btn-primary btn-xs" type="submit">Notify</button></td>' 
                + '<td><button name="remove" value="' + user.phone_number + '" class="btn btn-default btn-xs" type="submit">Remove</button></td>'
                + '</tr>'
            }
            targetContainer.innerHTML = user_table;
        };
    </script>
    <noscript>
        <meta http-equiv="refresh" content="30">
    </noscript>

</html>

このページにはフォームがあり、ボタンが押されたことに基づいてデータを送信するために使用されます。また、ステータスページと同様に、このページにもサーバーから送られたイベントを処理するためのJavascriptが含まれていますが、Javascriptが無効になっていても正常に動作します。

キックオフ

バックエンドとフロントエンドが完成したら、アプリケーションを実行することができます!前述したように、これは

python main.py

ブラウザでngrokのURLに移動し、ステータスビューを見る:

A screenshot of the status

管理ビューを見るには <ngrok url>/listにアクセスして管理ビューを見る。その後、リストに追加するために設定した番号に「Hi」とメールしてください!

A screenshot of the list view

次のステップ

このアプリケーションは、より堅牢なエクスペリエンスを提供するために強化することができるいくつかの方法があります。最も明白なのは、ステータスの更新に推定待ち時間を含めることだ。並んでいる人の待ち時間はすでに計算され、リストの並べ替えに使われている。もう一つのアイデアは、FacebookメッセンジャーやWhatsAppなど、SMS以外の選択肢をサポートすることだ。もしそのような方法を取りたいのであれば、Nexmoの メッセージAPIをご覧ください。

何か問題が発生したり、質問がある場合は、私たちの コミュニティ・スラック.お読みいただきありがとうございました!

シェア:

https://a.storyblok.com/f/270183/384x384/fdfdc77b6d/zachwalchuk.png
Zach Walchukヴォネージの卒業生

ザックはVonageの元開発者エデュケーターである。