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
1
app/phone/__init__.py
Normal file
1
app/phone/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
from .views import index, phone_reservation, twilio_callback
|
8
app/phone/base.py
Normal file
8
app/phone/base.py
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
phone_bp = Blueprint(
|
||||||
|
name="phone",
|
||||||
|
import_name=__name__,
|
||||||
|
url_prefix="/phone",
|
||||||
|
template_folder="templates",
|
||||||
|
)
|
0
app/phone/views/__init__.py
Normal file
0
app/phone/views/__init__.py
Normal file
128
app/phone/views/index.py
Normal file
128
app/phone/views/index.py
Normal 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)
|
48
app/phone/views/phone_reservation.py
Normal file
48
app/phone/views/phone_reservation.py
Normal 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(),
|
||||||
|
)
|
59
app/phone/views/twilio_callback.py
Normal file
59
app/phone/views/twilio_callback.py
Normal 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
37
poetry.lock
generated
|
@ -1239,6 +1239,20 @@ category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.5"
|
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]]
|
[[package]]
|
||||||
name = "pyopenssl"
|
name = "pyopenssl"
|
||||||
version = "19.1.0"
|
version = "19.1.0"
|
||||||
|
@ -1645,6 +1659,19 @@ ipython-genutils = "*"
|
||||||
[package.extras]
|
[package.extras]
|
||||||
test = ["pytest"]
|
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]]
|
[[package]]
|
||||||
name = "typed-ast"
|
name = "typed-ast"
|
||||||
version = "1.4.1"
|
version = "1.4.1"
|
||||||
|
@ -1853,7 +1880,7 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"]
|
||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "1.1"
|
lock-version = "1.1"
|
||||||
python-versions = "^3.7"
|
python-versions = "^3.7"
|
||||||
content-hash = "d5f89e2b5bb3c7b324e1e87441c64c048ef23c33782637a55e9d2c989c9d73a4"
|
content-hash = "05c6029909751452b1c44a746f95c6e960a4a14d897742aac2facc8d6a4a5cb6"
|
||||||
|
|
||||||
[metadata.files]
|
[metadata.files]
|
||||||
aiohttp = [
|
aiohttp = [
|
||||||
|
@ -2578,6 +2605,10 @@ pygments = [
|
||||||
{file = "Pygments-2.7.1-py3-none-any.whl", hash = "sha256:307543fe65c0947b126e83dd5a61bd8acbd84abec11f43caebaf5534cbc17998"},
|
{file = "Pygments-2.7.1-py3-none-any.whl", hash = "sha256:307543fe65c0947b126e83dd5a61bd8acbd84abec11f43caebaf5534cbc17998"},
|
||||||
{file = "Pygments-2.7.1.tar.gz", hash = "sha256:926c3f319eda178d1bd90851e4317e6d8cdb5e292a3386aac9bd75eca29cf9c7"},
|
{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 = [
|
pyopenssl = [
|
||||||
{file = "pyOpenSSL-19.1.0-py2.py3-none-any.whl", hash = "sha256:621880965a720b8ece2f1b2f54ea2071966ab00e2970ad2ce11d596102063504"},
|
{file = "pyOpenSSL-19.1.0-py2.py3-none-any.whl", hash = "sha256:621880965a720b8ece2f1b2f54ea2071966ab00e2970ad2ce11d596102063504"},
|
||||||
{file = "pyOpenSSL-19.1.0.tar.gz", hash = "sha256:9a24494b2602aaf402be5c9e30a0b82d4a5c67528fe8fb475e3f3bc00dd69507"},
|
{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-py3-none-any.whl", hash = "sha256:9664ec0c526e48e7b47b7d14cd6b252efa03e0129011de0a9c1d70315d4309c3"},
|
||||||
{file = "traitlets-5.0.4.tar.gz", hash = "sha256:86c9351f94f95de9db8a04ad8e892da299a088a64fd283f9f6f18770ae5eae1b"},
|
{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 = [
|
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_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"},
|
||||||
{file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"},
|
{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"
|
pyre2 = "^0.3.6"
|
||||||
tldextract = "^3.1.2"
|
tldextract = "^3.1.2"
|
||||||
flask-debugtoolbar-sqlalchemy = "^0.2.0"
|
flask-debugtoolbar-sqlalchemy = "^0.2.0"
|
||||||
|
twilio = "^7.3.2"
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
pytest = "^6.1.0"
|
pytest = "^6.1.0"
|
||||||
|
|
|
@ -98,6 +98,7 @@ from app.models import (
|
||||||
)
|
)
|
||||||
from app.monitor.base import monitor_bp
|
from app.monitor.base import monitor_bp
|
||||||
from app.oauth.base import oauth_bp
|
from app.oauth.base import oauth_bp
|
||||||
|
from app.phone.base import phone_bp
|
||||||
from app.utils import random_string
|
from app.utils import random_string
|
||||||
|
|
||||||
if SENTRY_DSN:
|
if SENTRY_DSN:
|
||||||
|
@ -214,6 +215,7 @@ def register_blueprints(app: Flask):
|
||||||
app.register_blueprint(monitor_bp)
|
app.register_blueprint(monitor_bp)
|
||||||
app.register_blueprint(dashboard_bp)
|
app.register_blueprint(dashboard_bp)
|
||||||
app.register_blueprint(developer_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="/oauth")
|
||||||
app.register_blueprint(oauth_bp, url_prefix="/oauth2")
|
app.register_blueprint(oauth_bp, url_prefix="/oauth2")
|
||||||
|
|
|
@ -82,5 +82,16 @@
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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>
|
</ul>
|
77
templates/phone/index.html
Normal file
77
templates/phone/index.html
Normal 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 %}
|
||||||
|
|
||||||
|
|
||||||
|
|
81
templates/phone/phone_reservation.html
Normal file
81
templates/phone/phone_reservation.html
Normal 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 %}
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue