Merge branch 'feature/zlib_url' into 'master'

Shorten the form URL by compressing and b64 encoding it

See merge request cara/cara!147
This commit is contained in:
Nicola Tarocco 2021-04-06 12:01:22 +00:00
commit 3d3127845c
7 changed files with 100 additions and 26 deletions

View file

@ -1,10 +1,12 @@
import datetime
import base64
import html
import json
import os
from pathlib import Path
import traceback
import uuid
import zlib
import jinja2
from tornado.web import Application, RequestHandler, StaticFileHandler
@ -86,8 +88,6 @@ class ConcentrationModel(BaseRequestHandler):
try:
form = model_generator.FormData.from_dict(requested_model_config)
except (KeyboardInterrupt, SystemExit):
raise
except Exception as err:
if self.settings.get("debug", False):
import traceback
@ -131,6 +131,18 @@ class CalculatorForm(BaseRequestHandler):
self.finish(report)
class CompressedCalculatorFormInputs(BaseRequestHandler):
def get(self, compressed_args: str):
# Convert a base64 zlib encoded shortened URL into a non compressed
# URL, and redirect.
try:
args = zlib.decompress(base64.b64decode(compressed_args)).decode()
except Exception as err: # noqa
self.set_status(400)
return self.finish("Invalid calculator data: it seems incomplete. Was there an error copying & pasting the URL?")
self.redirect(f'/calculator?{args}')
class ReadmeHandler(BaseRequestHandler):
def get(self):
template = self.settings['template_environment'].get_template("userguide.html.j2")
@ -146,6 +158,7 @@ def make_app(debug=False, prefix='/calculator'):
calculator_static_dir = Path(__file__).absolute().parent / 'static'
urls = [
(r'/?', LandingPage),
(r'/_c/(.*)', CompressedCalculatorFormInputs),
(r'/static/(.*)', StaticFileHandler, {'path': static_dir}),
(prefix + r'/?', CalculatorForm),
(prefix + r'/report', ConcentrationModel),

View file

@ -4,6 +4,7 @@ from datetime import datetime
import io
from pathlib import Path
import typing
import zlib
import qrcode
import urllib
@ -70,7 +71,17 @@ def calculate_report_data(model: models.ExposureModel):
def generate_qr_code(prefix, form: FormData):
form_dict = FormData.to_dict(form)
url = prefix + "?" + urllib.parse.urlencode(form_dict)
# Generate the calculator URL arguments that would be needed to re-create this
# form.
args = urllib.parse.urlencode(form_dict)
# Then zlib compress + base64 encode the string. To be inverted by the
# /_c/ endpoint.
qr_url = prefix + "/_c/" + base64.b64encode(
zlib.compress(args.encode())
).decode()
url = prefix + "/calculator?" + args
qr = qrcode.QRCode(
version=1,
@ -78,13 +89,14 @@ def generate_qr_code(prefix, form: FormData):
box_size=10,
border=4,
)
qr.add_data(url)
qr.add_data(qr_url)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white").convert('RGB')
return {
'image': img2base64(_img2bytes(img)),
'link': url,
'qr_url': qr_url,
}
@ -272,7 +284,7 @@ def build_report(base_url: str, model: models.ExposureModel, form: FormData):
context.update(calculate_report_data(model))
alternative_scenarios = manufacture_alternative_scenarios(form)
context['alternative_scenarios'] = comparison_report(alternative_scenarios)
context['qr_code'] = generate_qr_code(f'{base_url}/calculator', form)
context['qr_code'] = generate_qr_code(base_url, form)
cara_templates = Path(__file__).parent.parent / "templates"
calculator_templates = Path(__file__).parent / "templates"

View file

@ -0,0 +1,13 @@
import pytest
from cara.apps.calculator import model_generator
@pytest.fixture
def baseline_form_data():
return model_generator.baseline_raw_form_data()
@pytest.fixture
def baseline_form(baseline_form_data):
return model_generator.FormData.from_dict(baseline_form_data)

View file

@ -6,15 +6,6 @@ from cara import models
from cara import data
import numpy as np
@pytest.fixture
def baseline_form_data():
return model_generator.baseline_raw_form_data()
@pytest.fixture
def baseline_form(baseline_form_data):
return model_generator.FormData.from_dict(baseline_form_data)
def test_model_from_dict(baseline_form_data):
form = model_generator.FormData.from_dict(baseline_form_data)

View file

@ -1,19 +1,8 @@
import pytest
from cara.apps.calculator import model_generator
from cara.apps.calculator import report_generator
@pytest.fixture
def baseline_form_data():
return model_generator.baseline_raw_form_data()
@pytest.fixture
def baseline_form(baseline_form_data):
return model_generator.FormData.from_dict(baseline_form_data)
def test_generate_report(baseline_form):
model = baseline_form.build_model()
@ -32,4 +21,4 @@ def test_generate_report(baseline_form):
],
)
def test_readable_minutes(test_input, expected):
assert report_generator.readable_minutes(test_input) == expected
assert report_generator.readable_minutes(test_input) == expected

View file

@ -0,0 +1,54 @@
import pytest
from cara.apps.calculator import make_app
from cara.apps.calculator.report_generator import generate_qr_code
@pytest.fixture
def app():
return make_app()
async def test_homepage(http_server_client):
response = await http_server_client.fetch('/')
assert response.code == 200
async def test_calculator(http_server_client):
# Both with and without a trailing slash.
response = await http_server_client.fetch('/calculator')
assert response.code == 200
response = await http_server_client.fetch('/calculator/')
assert response.code == 200
async def test_qrcode_urls(http_server_client, baseline_form):
prefix = 'proto://hostname/prefix'
qr_data = generate_qr_code(prefix, baseline_form)
expected = f'{prefix}/calculator?activity_type=office&air_changes=0.0'
assert qr_data['link'].startswith(expected)
# We should get a 200 for the link.
response = await http_server_client.fetch(qr_data['link'].replace(prefix, ''))
assert response.code == 200
# And a 302 for the QR url itself. The redirected URL should be the same as
# in the link.
assert qr_data['qr_url'].startswith(prefix)
response = await http_server_client.fetch(
qr_data['qr_url'].replace(prefix, ''),
max_redirects=0,
raise_error=False,
)
assert response.code == 302
assert response.headers['Location'] == qr_data['link'].replace(prefix, '')
async def test_invalid_compressed_url(http_server_client, baseline_form):
response = await http_server_client.fetch(
'/_c/invalid-data',
max_redirects=0,
raise_error=False,
)
assert response.code == 400

View file

@ -71,6 +71,8 @@ setup(
'all': [req for reqs in REQUIREMENTS.values() for req in reqs],
},
package_data={'cara': [
'apps/templates/*.j2',
'apps/calculator/templates/*.j2',
'apps/calculator/*',
'apps/calculator/*/*',
'apps/calculator/*/*/*'