phone reservation page
- add twilio lib - create phone listing, reservation page - add twilio callback to receive messages
This commit is contained in:
parent
7109dc7120
commit
3e2c120a73
|
@ -0,0 +1 @@
|
|||
from .views import index, phone_reservation, twilio_callback
|
|
@ -0,0 +1,8 @@
|
|||
from flask import Blueprint
|
||||
|
||||
phone_bp = Blueprint(
|
||||
name="phone",
|
||||
import_name=__name__,
|
||||
url_prefix="/phone",
|
||||
template_folder="templates",
|
||||
)
|
|
@ -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)
|
|
@ -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(),
|
||||
)
|
|
@ -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)
|
|
@ -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"},
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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>
|
|
@ -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 %}
|
||||
|
||||
|
||||
|
|
@ -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 %}
|
||||
|
||||
|
||||
|
Loading…
Reference in New Issue