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 datetime
import base64
import html import html
import json import json
import os import os
from pathlib import Path from pathlib import Path
import traceback import traceback
import uuid import uuid
import zlib
import jinja2 import jinja2
from tornado.web import Application, RequestHandler, StaticFileHandler from tornado.web import Application, RequestHandler, StaticFileHandler
@ -86,8 +88,6 @@ class ConcentrationModel(BaseRequestHandler):
try: try:
form = model_generator.FormData.from_dict(requested_model_config) form = model_generator.FormData.from_dict(requested_model_config)
except (KeyboardInterrupt, SystemExit):
raise
except Exception as err: except Exception as err:
if self.settings.get("debug", False): if self.settings.get("debug", False):
import traceback import traceback
@ -131,6 +131,18 @@ class CalculatorForm(BaseRequestHandler):
self.finish(report) 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): class ReadmeHandler(BaseRequestHandler):
def get(self): def get(self):
template = self.settings['template_environment'].get_template("userguide.html.j2") 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' calculator_static_dir = Path(__file__).absolute().parent / 'static'
urls = [ urls = [
(r'/?', LandingPage), (r'/?', LandingPage),
(r'/_c/(.*)', CompressedCalculatorFormInputs),
(r'/static/(.*)', StaticFileHandler, {'path': static_dir}), (r'/static/(.*)', StaticFileHandler, {'path': static_dir}),
(prefix + r'/?', CalculatorForm), (prefix + r'/?', CalculatorForm),
(prefix + r'/report', ConcentrationModel), (prefix + r'/report', ConcentrationModel),

View file

@ -4,6 +4,7 @@ from datetime import datetime
import io import io
from pathlib import Path from pathlib import Path
import typing import typing
import zlib
import qrcode import qrcode
import urllib import urllib
@ -70,7 +71,17 @@ def calculate_report_data(model: models.ExposureModel):
def generate_qr_code(prefix, form: FormData): def generate_qr_code(prefix, form: FormData):
form_dict = FormData.to_dict(form) 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( qr = qrcode.QRCode(
version=1, version=1,
@ -78,13 +89,14 @@ def generate_qr_code(prefix, form: FormData):
box_size=10, box_size=10,
border=4, border=4,
) )
qr.add_data(url) qr.add_data(qr_url)
qr.make(fit=True) qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white").convert('RGB') img = qr.make_image(fill_color="black", back_color="white").convert('RGB')
return { return {
'image': img2base64(_img2bytes(img)), 'image': img2base64(_img2bytes(img)),
'link': url, '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)) context.update(calculate_report_data(model))
alternative_scenarios = manufacture_alternative_scenarios(form) alternative_scenarios = manufacture_alternative_scenarios(form)
context['alternative_scenarios'] = comparison_report(alternative_scenarios) 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" cara_templates = Path(__file__).parent.parent / "templates"
calculator_templates = Path(__file__).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 from cara import data
import numpy as np 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): def test_model_from_dict(baseline_form_data):
form = model_generator.FormData.from_dict(baseline_form_data) form = model_generator.FormData.from_dict(baseline_form_data)

View file

@ -1,19 +1,8 @@
import pytest import pytest
from cara.apps.calculator import model_generator
from cara.apps.calculator import report_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): def test_generate_report(baseline_form):
model = baseline_form.build_model() model = baseline_form.build_model()
@ -32,4 +21,4 @@ def test_generate_report(baseline_form):
], ],
) )
def test_readable_minutes(test_input, expected): 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], 'all': [req for reqs in REQUIREMENTS.values() for req in reqs],
}, },
package_data={'cara': [ package_data={'cara': [
'apps/templates/*.j2',
'apps/calculator/templates/*.j2',
'apps/calculator/*', 'apps/calculator/*',
'apps/calculator/*/*', 'apps/calculator/*/*',
'apps/calculator/*/*/*' 'apps/calculator/*/*/*'