https://d226lax1qjow5r.cloudfront.net/blog/blogposts/use-python-and-flask-to-manage-queues-via-sms-dr/manage-queues_1200x600.png

Utiliser Python et Flask pour gérer des files d'attente par SMS

Publié le May 18, 2021

Temps de lecture : 18 minutes

Si de nombreuses expériences humaines peuvent être améliorées grâce à l'application de la technologie, il en est une qui est clairement mûre pour une solution moderne : la file d'attente. Que vous souhaitiez renouveler votre permis de conduire ou obtenir une place à un brunch populaire, le temps passé dans des salles d'attente avec des inconnus est quelque chose que vous aimeriez sans doute éviter. Heureusement, grâce à l'omniprésence des téléphones portables, les nouveaux systèmes de file d'attente basés sur le texte et le web sont de plus en plus courants : envoyez un SMS pour réserver votre place dans la file, vérifiez les mises à jour de votre statut et recevez une notification lorsque vous avez atteint le début de la file d'attente.

Dans ce tutoriel, je vous montrerai comment utiliser Python et l'outil Flask pour construire un système simple de gestion de file d'attente de SMS. Il y a trois composants principaux :

  • Un backend pour répondre aux messages textuels et prendre les mesures appropriées

  • Une page d'état accessible via le web ou affichée dans un kiosque

  • Une page de gestion qui dresse la liste des personnes en ligne et permet de les notifier ou de les retirer.

Pour rester simple, cette application ne gère que les fonctions les plus élémentaires, mais le squelette qu'elle fournit devrait permettre de construire facilement un système robuste répondant à vos besoins.

Conditions préalables

  • Python (ce code a été testé avec la version 3.8)

  • ngrok

Vonage API Account

To complete this tutorial, you will need a Vonage API account. If you don’t have one already, you can sign up today and start building with free credit. Once you have an account, you can find your API Key and API Secret at the top of the Vonage API Dashboard.

This tutorial also uses a virtual phone number. To purchase one, go to Numbers > Buy Numbers and search for one that meets your needs.

Mise en place

Pour commencer, créez un nouveau répertoire pour le projet et accédez-y :

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

Si vous préférez commencer avec le code final, vous pouvez cloner le projet projet d'exemple sur le GitHub de la communauté Nexmo :

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

Une fois dans le répertoire de votre projet, créez et activez un environnement virtuel :

python3 -m venv venv source venv/bin/activate

L'environnement virtuel vous aide à gérer et à isoler les dépendances de votre projet. Pour installer les dépendances nécessaires à ce projet, vous devez disposer d'un fichier requirements.txt (en anglais). Si vous avez cloné le repo d'exemple, vous êtes déjà prêt, mais si vous construisez à partir de zéro, votre fichier devrait ressembler à ceci :

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

Pour installer ces dépendances, exécutez la commande suivante dans le répertoire de votre projet :

pip install -r requirements.txt

Initialisation de la base de données

Avant que votre application ne puisse fonctionner correctement, vous devrez mettre en place une base de données pour stocker les informations sur les personnes qui font la queue. Étant donné que les exigences en matière de stockage de données pour ce projet sont relativement simples, vous utiliserez la base de données intégrée à Python, SQLite. Vous gérerez votre base de données avec Flask-SQLAlchemyqui fournit une couche au-dessus de la base de données afin que vous puissiez faire des requêtes avec des fonctions simples plutôt que d'écrire du SQL.

Si vous n'avez pas téléchargé le code à partir du répertoire d'exemples, créez un nouveau fichier nommé main.pycontenant les éléments suivants :

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)

Ce script met en place votre application Flask et la configuration de base de la base de données, y compris la définition d'un nom et d'un emplacement pour la base de données, ainsi que la définition des noms et des types de données des colonnes de la base de données. Une fois que vous avez ce script ou l'exemple main.pylancez Python en mode interactif :

python

Exécutez ensuite les commandes suivantes pour configurer votre base de données :

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

Configuration de ngrok et Nexmo

Maintenant que vous avez une base de données prête à l'emploi, vous devez effectuer quelques réglages de base afin d'utiliser Nexmo pour envoyer et recevoir des messages texte. Pour commencer, assurez-vous que ngrok est installé et fonctionne sur le port 5000 :

ngrok http 5000

Lorsque ngrok est lancé, il vous sera présenté une URL à utiliser pour la redirection vers votre serveur local. Notez cette URL, car vous l'utiliserez bientôt :

An example of an ngrok instance running

Sur la page principale de votre tableau de bord Nexmo, localisez votre clé et votre secret :

Screenshot of the Nexmo dashboard

Copiez-les et placez-les au début de votre fichier main.py comme suit :

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

Ajoutez ensuite cette ligne :

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

De retour au tableau de bord, naviguez vers Numbers -> Vos Numbers (si vous n'avez pas encore de numéro, allez sur Numbers -> Acheter des numéros d'abord). Survolez votre numéro jusqu'à ce que vous aperceviez le bouton Copier jusqu'à ce que vous voyiez le bouton Copier. Cliquez dessus, puis collez le numéro dans le champ main.py comme suit :

NEXMO_NUMBER = "<Your Nexmo Number>"

Pendant que vous êtes ici, cliquez sur l'icône de l'engrenage sous l'onglet Gérer sous la colonne Gérer :

A screenshot of a page showing your rented numbers

Dans l'URL du URL du Webhook entrant entrez ce qui suit, en utilisant l'URL ngrok que vous avez recueillie plus tôt :

<Your ngrok URL>/webhooks/inbound-sms

Defining webhook urls

Sauvegarder ce réglage.

Si vous travaillez à partir du repo d'exemples, vous avez maintenant tout ce qu'il faut pour exécuter l'application. Entrez ce qui suit dans le terminal pour démarrer votre serveur de développement :

python main.py

Dans un navigateur, naviguez jusqu'à l'URL de votre ngrok pour voir la vue de l'état, ou allez à <ngrok url>/list pour voir la vue de gestion. Envoyez ensuite un message "Bonjour" au numéro que vous avez configuré pour vous ajouter à la liste !

Pour ceux qui partent de zéro, passons à la création de la logique du backend.

Backend

La structure du backend de cette application est relativement simple. Vous allez créer quatre routes - deux associées aux vues frontales, un webhook pour recevoir les SMS entrants et un flux pour publier les événements envoyés par le serveur. événements envoyés par le serveur. En plus des routes, vous aurez besoin d'un ensemble de fonctions qui effectuent des actions basées sur les messages reçus et d'autres entrées utilisateur. Enfin, vous disposerez de quelques fonctions d'aide pour gérer les requêtes de la base de données et formater le texte.

Commençons par les routes pour les vues. Ajoutez ce qui suit à main.py après la section où vous initialisez la base de données et avant 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)

L'itinéraire index est simple : lorsque quelqu'un visitera le site, il verra le contenu de la page index.html (à créer), qui contiendra des informations sur la longueur de la ligne et le numéro de téléphone à appeler (à l'aide de fonctions que vous écrirez bientôt).

L'itinéraire list contient un peu plus d'informations, car cette page permet de saisir des données. Si la page est consultée (requête GET), le visiteur verra le contenu de list.htmlavec des informations sur l'utilisateur (numéros de téléphone des personnes dans la file d'attente). Si une requête POST est adressée à cette route via le formulaire de la page, les informations de la requête seront utilisées pour déterminer quel bouton a été pressé. La route notify, remove, et query_users seront définies plus loin.

Maintenant que ces routes sont définies, il est temps d'ajouter la route 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)

Vous reconnaissez peut-être cette route à l'étape de configuration précédente. Lorsque Numbers reçoit un SMS au numéro que vous avez configuré avec ce webhook, vous obtenez un objet de requête contenant le contenu du message et des informations sur l'expéditeur. Pour nos besoins, nous extrayons le numéro de téléphone de l'expéditeur et le texte du message, en associant le texte aux fonctions d'action appropriées. Si nous ne parvenons pas à analyser le message, nous en informons l'expéditeur afin qu'il puisse réessayer.

Avant de créer l'itinéraire final, définissons nos fonctions d'aide et d'action. Incluez les éléments suivants dans main.py après les définitions d'itinéraires :

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

Ces fonctions d'aide se présentent comme suit :

  • phone_format: Diviser le numéro de téléphone configuré avec des traits d'union à des fins d'affichage.

  • query_length: Interroger la base de données pour savoir combien de personnes attendent dans la file d'attente.

  • query_users: Interroger la base de données pour tous les utilisateurs et formater le résultat de manière à ce que les personnes en attente et celles qui ont déjà été notifiées soient regroupées séparément.

  • send: Envoyer un SMS avec le numéro et le texte fournis.

Ensuite, ajoutez ces fonctions d'action :

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

En résumé :

  • add: Ajoute un nouvel utilisateur lorsque le message "Hi" est envoyé. Renvoie également le statut de l'utilisateur s'il est déjà en ligne.

  • remove: Supprime un utilisateur de la liste. Peut être déclenché à partir d'un bouton sur la page de gestion ou si l'utilisateur envoie le texte "annuler".

  • notify: Indique à l'utilisateur que c'est son tour. Déclenché à partir d'un bouton sur la page de gestion. Calcule également le temps d'attente total pour cet utilisateur et le stocke dans la base de données.

  • status: Indique à l'utilisateur la place qu'il occupe dans la ligne.

  • help: Fournit des informations de base sur les commandes que l'utilisateur peut envoyer.

Avec ces routes et ces fonctions définies, vous avez presque tout ce dont vous avez besoin pour un backend complet. La quatrième et dernière route nous permet de mettre à jour dynamiquement les vues en fonction des messages provenant du serveur, ce qui est utile lorsque des personnes sont ajoutées ou retirées de la liste par SMS :

@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")

Cette route fonctionne différemment des précédentes. Après avoir été appelée par le client, la fonction continue à fonctionner jusqu'à ce que le serveur s'arrête. Cela lui permet de maintenir une connexion avec le client, en envoyant des mises à jour chaque fois que le nombre de personnes dans la file d'attente change.

Maintenant que vous avez tous les éléments du backend, il est temps de créer les vues !

Frontend

Vous devez créer deux vues : une pour la page d'état et une pour la page de gestion. Commencez par créer un nouveau répertoire, templateset un nouveau fichier dans ce répertoire, index.html. Dans ce fichier, mettez ce qui suit :

<!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>

Quelques points à noter ici. Tout d'abord, vous verrez que nous utilisons Bootstrap pour créer un style de base. Vous pouvez en savoir plus sur l'utilisation de Bootstrap dans l'un de nos d'il y a quelques semaines. Deuxièmement, vous remarquerez que les éléments {{ length }} et {{ number }} où nous recevons des données du backend lorsque le modèle est rendu. Enfin, voyez le court extrait Javascript à la fin - il se connecte à notre point de terminaison stream et traite les événements envoyés par le serveur, en mettant à jour le nombre d'utilisateurs en ligne de manière dynamique.

Il y a également une courte section noscript qui est configurée pour rafraîchir la page toutes les trente secondes si Javascript a été désactivé. De la manière dont cette application a été conçue, elle pourra toujours fonctionner correctement sans Javascript, mais la mise à jour ne sera pas aussi instantanée.

Pour la page de gestion, créez un nouveau fichier list.html et incluez les éléments suivants :

<!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>

Sur cette page, vous remarquerez qu'il y a un formulaire, qui est utilisé pour soumettre des données en fonction des boutons pressés. Comme la page d'état, cette page contient du Javascript pour traiter les événements envoyés par le serveur, mais elle continuera à fonctionner correctement si le Javascript a été désactivé.

Le coup d'envoi

Le backend et le frontend étant terminés, vous pouvez maintenant exécuter votre application ! Comme nous l'avons mentionné plus haut, cela se fait avec :

python main.py

Dans un navigateur, naviguez jusqu'à l'URL de votre ngrok pour voir l'état d'avancement :

A screenshot of the status

Allez à <ngrok url>/list pour voir la vue de gestion. Envoyez ensuite "Bonjour" au numéro que vous avez configuré pour vous ajouter à la liste !

A screenshot of the list view

Prochaines étapes

Il y a plusieurs façons d'améliorer cette application afin de fournir une expérience plus robuste. La plus évidente est d'inclure une estimation du temps d'attente dans les mises à jour de l'état. Les temps d'attente des personnes qui font la queue sont déjà calculés et utilisés pour trier la liste, il vous suffirait donc de choisir l'algorithme que vous préférez pour faire des estimations. Une autre idée serait de prendre en charge plus d'options que les SMS, comme Facebook Messenger ou WhatsApp. Si vous souhaitez emprunter cette voie, n'hésitez pas à consulter l'application Nexmo Messages API.

Si vous rencontrez des problèmes ou si vous avez des questions, contactez-nous sur notre Communauté Slack. Merci pour votre lecture !

Partager:

https://a.storyblok.com/f/270183/384x384/fdfdc77b6d/zachwalchuk.png
Zach WalchukAnciens de Vonage

Zach est un ancien éducateur de développeurs chez Vonage.