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:
commit
3d3127845c
7 changed files with 100 additions and 26 deletions
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
13
cara/tests/apps/calculator/conftest.py
Normal file
13
cara/tests/apps/calculator/conftest.py
Normal 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)
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
54
cara/tests/apps/calculator/test_webapp.py
Normal file
54
cara/tests/apps/calculator/test_webapp.py
Normal 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
|
||||||
2
setup.py
2
setup.py
|
|
@ -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/*/*/*'
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue