phone reservation page

- add twilio lib
- create phone listing, reservation page
- add twilio callback to receive messages
This commit is contained in:
Son 2021-12-02 17:03:13 +01:00
parent 7109dc7120
commit 3e2c120a73
12 changed files with 452 additions and 1 deletions

1
app/phone/__init__.py Normal file
View File

@ -0,0 +1 @@
from .views import index, phone_reservation, twilio_callback

8
app/phone/base.py Normal file
View File

@ -0,0 +1,8 @@
from flask import Blueprint
phone_bp = Blueprint(
name="phone",
import_name=__name__,
url_prefix="/phone",
template_folder="templates",
)

View File

128
app/phone/views/index.py Normal file
View File

@ -0,0 +1,128 @@
from typing import Dict
import arrow
from flask import render_template, request, flash, redirect, url_for
from flask_login import login_required, current_user
from sqlalchemy import func
from app.db import Session
from app.models import PhoneCountry, PhoneNumber, PhoneReservation
from app.phone.base import phone_bp
@phone_bp.route("/", methods=["GET", "POST"])
@login_required
def index():
if not current_user.can_use_phone:
flash("You can't use this page", "error")
return redirect(url_for("dashboard.index"))
countries = available_countries()
now = arrow.now()
reservations = PhoneReservation.filter(
PhoneReservation.user_id == current_user.id,
PhoneReservation.start < now,
PhoneReservation.end > now,
).all()
past_reservations = PhoneReservation.filter(
PhoneReservation.user_id == current_user.id,
PhoneReservation.end <= now,
).all()
if request.method == "POST":
nb_minute = int(request.form.get("minute"))
if current_user.phone_quota < nb_minute:
flash(
f"You don't have enough phone quota. Current quota is {current_user.phone_quota}",
"error",
)
return redirect(request.url)
country_id = request.form.get("country")
country = PhoneCountry.get(country_id)
# get the first phone number available
now = arrow.now()
busy_phone_number_subquery = (
Session.query(PhoneReservation.number_id)
.filter(PhoneReservation.start < now, PhoneReservation.end > now)
.subquery()
)
phone_number = (
Session.query(PhoneNumber)
.filter(
PhoneNumber.country_id == country.id,
PhoneNumber.id.notin_(busy_phone_number_subquery),
)
.first()
)
if phone_number:
phone_reservation = PhoneReservation.create(
number_id=phone_number.id,
start=arrow.now(),
end=arrow.now().shift(minutes=nb_minute),
user_id=current_user.id,
)
current_user.phone_quota -= nb_minute
Session.commit()
return redirect(
url_for("phone.reservation_route", reservation_id=phone_reservation.id)
)
else:
flash(
f"No phone number available for {country.name} during {nb_minute} minutes"
)
return render_template(
"phone/index.html",
countries=countries,
reservations=reservations,
past_reservations=past_reservations,
)
def available_countries() -> [PhoneCountry]:
now = arrow.now()
phone_count_by_countries: Dict[PhoneCountry, int] = dict()
for country, count in (
Session.query(PhoneCountry, func.count(PhoneNumber.id))
.join(PhoneNumber, PhoneNumber.country_id == PhoneCountry.id)
.filter(PhoneNumber.active.is_(True))
.group_by(PhoneCountry)
.all()
):
phone_count_by_countries[country] = count
busy_phone_count_by_countries: Dict[PhoneCountry, int] = dict()
for country, count in (
Session.query(PhoneCountry, func.count(PhoneNumber.id))
.join(PhoneNumber, PhoneNumber.country_id == PhoneCountry.id)
.join(PhoneReservation, PhoneReservation.number_id == PhoneNumber.id)
.filter(PhoneReservation.start < now, PhoneReservation.end > now)
.group_by(PhoneCountry)
.all()
):
busy_phone_count_by_countries[country] = count
ret = []
for country in phone_count_by_countries:
if (
country not in busy_phone_count_by_countries
or phone_count_by_countries[country]
> busy_phone_count_by_countries[country]
):
ret.append(country)
return ret
def available_numbers() -> [PhoneNumber]:
Session.query(PhoneReservation).filter(PhoneReservation.start)

View File

@ -0,0 +1,48 @@
import arrow
from flask import render_template, flash, redirect, url_for, request
from flask_login import login_required, current_user
from app.db import Session
from app.models import PhoneReservation, PhoneMessage, User
from app.phone.base import phone_bp
current_user: User
@phone_bp.route("/reservation/<reservation_id>", methods=["GET", "POST"])
@login_required
def reservation_route(reservation_id: int):
reservation: PhoneReservation = PhoneReservation.get(reservation_id)
if not reservation or reservation.user_id != current_user.id:
flash("Unknown error, redirect back to phone page", "warning")
return redirect(url_for("phone.index"))
phone_number = reservation.number
messages = PhoneMessage.filter(
PhoneMessage.number_id == phone_number.id,
PhoneMessage.created_at > reservation.start,
PhoneMessage.created_at < reservation.end,
).all()
if request.method == "POST":
if request.form.get("form-name") == "release":
time_left = reservation.end - arrow.now()
if time_left.seconds > 0:
current_user.phone_quota += time_left.seconds // 60
flash(
f"Your phone quota is increased by {time_left.seconds // 60} minutes",
"success",
)
reservation.end = arrow.now()
Session.commit()
flash(f"{phone_number.number} is released", "success")
return redirect(url_for("phone.index"))
return render_template(
"phone/phone_reservation.html",
phone_number=phone_number,
reservation=reservation,
messages=messages,
now=arrow.now(),
)

View File

@ -0,0 +1,59 @@
from functools import wraps
from flask import request, abort
from twilio.request_validator import RequestValidator
from twilio.twiml.messaging_response import MessagingResponse
from app.config import TWILIO_AUTH_TOKEN
from app.log import LOG
from app.models import PhoneNumber, PhoneMessage
from app.phone.base import phone_bp
def validate_twilio_request(f):
"""Validates that incoming requests genuinely originated from Twilio"""
@wraps(f)
def decorated_function(*args, **kwargs):
# Create an instance of the RequestValidator class
validator = RequestValidator(TWILIO_AUTH_TOKEN)
# Validate the request using its URL, POST data,
# and X-TWILIO-SIGNATURE header
request_valid = validator.validate(
request.url, request.form, request.headers.get("X-TWILIO-SIGNATURE", "")
)
# Continue processing the request if it's valid, return a 403 error if
# it's not
if request_valid:
return f(*args, **kwargs)
else:
return abort(403)
return decorated_function
@phone_bp.route("/twilio/sms", methods=["GET", "POST"])
@validate_twilio_request
def twilio_sms():
LOG.d("%s %s %s", request.args, request.form, request.data)
resp = MessagingResponse()
to_number = request.form.get("To")
from_number = request.form.get("From")
body = request.form.get("Body")
LOG.d("%s->%s:%s", from_number, to_number, body)
phone_number = PhoneNumber.get_by(number=to_number)
if phone_number:
PhoneMessage.create(
number_id=phone_number.id,
from_number=from_number,
body=body,
commit=True,
)
else:
LOG.e("Unknown phone number %s %s", to_number, request.form)
return str(resp)

37
poetry.lock generated
View File

@ -1239,6 +1239,20 @@ category = "main"
optional = false
python-versions = ">=3.5"
[[package]]
name = "pyjwt"
version = "2.3.0"
description = "JSON Web Token implementation in Python"
category = "main"
optional = false
python-versions = ">=3.6"
[package.extras]
crypto = ["cryptography (>=3.3.1)"]
dev = ["sphinx", "sphinx-rtd-theme", "zope.interface", "cryptography (>=3.3.1)", "pytest (>=6.0.0,<7.0.0)", "coverage[toml] (==5.0.4)", "mypy", "pre-commit"]
docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"]
tests = ["pytest (>=6.0.0,<7.0.0)", "coverage[toml] (==5.0.4)"]
[[package]]
name = "pyopenssl"
version = "19.1.0"
@ -1645,6 +1659,19 @@ ipython-genutils = "*"
[package.extras]
test = ["pytest"]
[[package]]
name = "twilio"
version = "7.3.2"
description = "Twilio API client and TwiML generator"
category = "main"
optional = false
python-versions = ">=3.6.0"
[package.dependencies]
PyJWT = ">=2.0.0,<3.0.0"
pytz = "*"
requests = ">=2.0.0"
[[package]]
name = "typed-ast"
version = "1.4.1"
@ -1853,7 +1880,7 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"]
[metadata]
lock-version = "1.1"
python-versions = "^3.7"
content-hash = "d5f89e2b5bb3c7b324e1e87441c64c048ef23c33782637a55e9d2c989c9d73a4"
content-hash = "05c6029909751452b1c44a746f95c6e960a4a14d897742aac2facc8d6a4a5cb6"
[metadata.files]
aiohttp = [
@ -2578,6 +2605,10 @@ pygments = [
{file = "Pygments-2.7.1-py3-none-any.whl", hash = "sha256:307543fe65c0947b126e83dd5a61bd8acbd84abec11f43caebaf5534cbc17998"},
{file = "Pygments-2.7.1.tar.gz", hash = "sha256:926c3f319eda178d1bd90851e4317e6d8cdb5e292a3386aac9bd75eca29cf9c7"},
]
pyjwt = [
{file = "PyJWT-2.3.0-py3-none-any.whl", hash = "sha256:e0c4bb8d9f0af0c7f5b1ec4c5036309617d03d56932877f2f7a0beeb5318322f"},
{file = "PyJWT-2.3.0.tar.gz", hash = "sha256:b888b4d56f06f6dcd777210c334e69c737be74755d3e5e9ee3fe67dc18a0ee41"},
]
pyopenssl = [
{file = "pyOpenSSL-19.1.0-py2.py3-none-any.whl", hash = "sha256:621880965a720b8ece2f1b2f54ea2071966ab00e2970ad2ce11d596102063504"},
{file = "pyOpenSSL-19.1.0.tar.gz", hash = "sha256:9a24494b2602aaf402be5c9e30a0b82d4a5c67528fe8fb475e3f3bc00dd69507"},
@ -2870,6 +2901,10 @@ traitlets = [
{file = "traitlets-5.0.4-py3-none-any.whl", hash = "sha256:9664ec0c526e48e7b47b7d14cd6b252efa03e0129011de0a9c1d70315d4309c3"},
{file = "traitlets-5.0.4.tar.gz", hash = "sha256:86c9351f94f95de9db8a04ad8e892da299a088a64fd283f9f6f18770ae5eae1b"},
]
twilio = [
{file = "twilio-7.3.2-py2.py3-none-any.whl", hash = "sha256:6cc6ed114b07a7ce853503a5a27281f56237b411ea415012955cff3a57045f1b"},
{file = "twilio-7.3.2.tar.gz", hash = "sha256:3170da33c7f4293bbebcd032b183866e044fcf8418e5c5e15bdd5ec7a0a958b6"},
]
typed-ast = [
{file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"},
{file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"},

View File

@ -81,6 +81,7 @@ flanker = "^0.9.11"
pyre2 = "^0.3.6"
tldextract = "^3.1.2"
flask-debugtoolbar-sqlalchemy = "^0.2.0"
twilio = "^7.3.2"
[tool.poetry.dev-dependencies]
pytest = "^6.1.0"

View File

@ -98,6 +98,7 @@ from app.models import (
)
from app.monitor.base import monitor_bp
from app.oauth.base import oauth_bp
from app.phone.base import phone_bp
from app.utils import random_string
if SENTRY_DSN:
@ -214,6 +215,7 @@ def register_blueprints(app: Flask):
app.register_blueprint(monitor_bp)
app.register_blueprint(dashboard_bp)
app.register_blueprint(developer_bp)
app.register_blueprint(phone_bp)
app.register_blueprint(oauth_bp, url_prefix="/oauth")
app.register_blueprint(oauth_bp, url_prefix="/oauth2")

View File

@ -82,5 +82,16 @@
</a>
</li>
{% if current_user.can_use_phone %}
<li class="nav-item">
<a href="{{ url_for('phone.index') }}"
class="nav-link {{ 'active' if active_page == 'phone' }}">
<i class="fe fe-phone"></i>
Phone
<span class="badge badge-warning" style="line-height: .7em; margin-top: 2px">Beta</span>
</a>
</li>
{% endif %}
</ul>

View File

@ -0,0 +1,77 @@
{% extends 'default.html' %}
{% set active_page = "phone" %}
{% block title %}
Phone numbers
{% endblock %}
{% block default_content %}
<div class="card">
<div class="card-body">
<h3>Your current numbers</h3>
{% for reservation in reservations %}
<div>
<a href="{{ url_for('phone.reservation_route', reservation_id=reservation.id ) }}">
{{ reservation.number.number }} ➡
</a>
</div>
{% endfor %}
</div>
</div>
<div class="card">
<div class="card-body">
<h3>Phone Reservation</h3>
<div class="alert alert-info">
Currently your phone quota is <b>{{ current_user.phone_quota }}</b> minutes.
</div>
<form method="post">
<div class="form-group">
<label for="input-minute">How many minutes do you need this number for?</label>
<input name="minute" type="number" class="form-control" id="input-minute" aria-describedby="emailHelp"
placeholder="5, 10, 60, etc.">
<small id="emailHelp" class="form-text text-muted">We'll never share your email with anyone else.</small>
</div>
<div class="form-group">
<label>Countries</label> <br>
{% for country in countries %}
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio"
name="country"
id="country-{{ country.id }}" value="{{ country.id }}">
<label class="form-check-label" for="country-{{ country.id }}">{{ country.name }}</label>
</div>
{% endfor %}
</div>
<button type="submit" class="btn btn-primary">Get my number</button>
</form>
</div>
</div>
<div class="card">
<div class="card-body">
<h3>Past Reservations</h3>
{% for reservation in past_reservations %}
<div>
<a href="{{ url_for('phone.reservation_route', reservation_id=reservation.id ) }}" class="mr-3">
{{ reservation.number.number }} ➡
</a>
ended {{ reservation.end.humanize() }}
</div>
{% endfor %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,81 @@
{% extends 'default.html' %}
{% set active_page = "phone" %}
{% block title %}
Phone reservation {{ phone_number.number }}
{% endblock %}
{% block default_content %}
<div class="card">
<div class="card-body">
Your number is
<div class="d-flex mt-3">
<h2>{{ phone_number.number }}</h2>
<div class="ml-3">
<button
data-clipboard-text="{{ phone_number.number }}"
class="clipboard btn btn-outline-primary btn-sm" type="button">
<i class="fe fe-clipboard"></i>
</button>
</div>
</div>
{% if now > reservation.end %}
was ended {{ reservation.end.humanize() }}
{% else %}
will be released {{ reservation.end.humanize() }}
{% endif %}
</div>
</div>
<div class="card">
<div class="card-body">
<h2 class="mb-2">Received Messages</h2>
<div class="mb-4">Please refresh the page to have the latest messages</div>
<table class="table">
<thead>
<tr>
<th scope="col">From</th>
<th scope="col">Time</th>
<th scope="col">Message</th>
</tr>
</thead>
<tbody>
{% for message in messages %}
<tr>
<td>{{ message.from_number }}</td>
<td>{{ message.created_at.humanize() }}</td>
<td>{{ message.body }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% if now < reservation.end %}
<div class="card">
<div class="card-body">
When the number is released, you can't reclaim it.
<form method="post" class="mt-3">
<input type="hidden" name="form-name" value="release">
<button class="btn btn-outline-danger">Release the number</button>
</form>
</div>
</div>
{% endif %}
{% endblock %}