Merge branch 'feature/configurable_reports' into 'master'

Allow theming of CARA (initially in the report only, to be extended later on)

Closes #157

See merge request cara/cara!160
This commit is contained in:
Philip James Elson 2021-04-21 14:06:09 +00:00
commit da5cbc6891
12 changed files with 271 additions and 102 deletions

View file

@ -38,6 +38,12 @@ pip install -e . # At the root of the repository
python -m cara.apps.calculator
```
To run with the CERN theme:
```
python -m cara.apps.calculator --theme=cara/apps/calculator/themes/cern
```
### Running the CARA Expert-App app locally
```

View file

@ -260,8 +260,15 @@
containers:
- name: cara-webservice
env:
- name: COOKIE_SECRET
valueFrom:
secretKeyRef:
key: COOKIE_SECRET
name: auth-service-secrets
- name: APP_NAME
value: cara-webservice
- name: CARA_THEME
value: cara/apps/calculator/themes/cern
image: '${PROJECT_NAME}/cara-webservice'
ports:
- containerPort: 8080

15
app.sh
View file

@ -1,6 +1,17 @@
#!/bin/bash
if [[ "$APP_NAME" == "cara-webservice" ]]; then
echo "Starting the cara webservice"
python -m cara.apps.calculator --no-debug
args=("$@")
if [ "$DEBUG" != "true" ] && [[ ! "${args[@]}" =~ "--no-debug" ]]; then
args+=("--no-debug")
fi
if [ ! -z "$CARA_THEME" ]; then
args+=("--theme=${CARA_THEME}")
fi
echo "Starting the cara webservice with: python -m cara.apps.calculator ${args[@]}"
python -m cara.apps.calculator "${args[@]}"
elif [[ "$APP_NAME" == "cara-voila" ]]; then
echo "Starting the voila service"
voila app/ --port=8080 --no-browser --base_url=/voila-server/ --Voila.tornado_settings="{'allow_origin': '*'}"

View file

@ -5,6 +5,7 @@ import json
import os
from pathlib import Path
import traceback
import typing
import uuid
import zlib
@ -12,13 +13,13 @@ import jinja2
from tornado.web import Application, RequestHandler, StaticFileHandler
from . import model_generator
from .report_generator import build_report
from .report_generator import ReportGenerator
from .user import AuthenticatedUser, AnonymousUser
# The calculator version is based on a combination of the model version and the
# semantic version of the calculator itself. The version uses the terms
# "{MAJOR}.{MINOR}.{PATCH}" to describe the 3 distinct numbers constituing a version.
# "{MAJOR}.{MINOR}.{PATCH}" to describe the 3 distinct numbers constituting a version.
# Effectively, if the model increases its MAJOR version then so too should this
# calculator version. If the calculator needs to make breaking changes (e.g. change
# form attributes) then it can also increase its MAJOR version without needing to
@ -98,16 +99,17 @@ class ConcentrationModel(BaseRequestHandler):
return
base_url = self.request.protocol + "://" + self.request.host
report = build_report(base_url, form.build_model(), form)
report_generator = self.settings['report_generator']
report = report_generator.build_report(base_url, form)
self.finish(report)
class StaticModel(BaseRequestHandler):
def get(self):
form = model_generator.FormData.from_dict(model_generator.baseline_raw_form_data())
model = form.build_model()
base_url = self.request.protocol + "://" + self.request.host
report = build_report(base_url, model, form)
report_generator = self.settings['report_generator']
report = report_generator.build_report(base_url, form)
self.finish(report)
@ -153,10 +155,14 @@ class ReadmeHandler(BaseRequestHandler):
self.finish(readme)
def make_app(debug=False, prefix='/calculator'):
def make_app(
debug: bool = False,
prefix: str = '/calculator',
theme_dir: typing.Optional[Path] = None,
) -> Application:
static_dir = Path(__file__).absolute().parent.parent / 'static'
calculator_static_dir = Path(__file__).absolute().parent / 'static'
urls = [
urls: typing.Any = [
(r'/?', LandingPage),
(r'/_c/(.*)', CompressedCalculatorFormInputs),
(r'/static/(.*)', StaticFileHandler, {'path': static_dir}),
@ -169,8 +175,12 @@ def make_app(debug=False, prefix='/calculator'):
cara_templates = Path(__file__).parent.parent / "templates"
calculator_templates = Path(__file__).parent / "templates"
templates_directories = [cara_templates, calculator_templates]
if theme_dir:
templates_directories.insert(0, theme_dir / 'templates')
loader = jinja2.FileSystemLoader([str(path) for path in templates_directories])
template_environment = jinja2.Environment(
loader=jinja2.FileSystemLoader([cara_templates, calculator_templates]),
loader=loader,
)
return Application(
@ -178,6 +188,7 @@ def make_app(debug=False, prefix='/calculator'):
debug=debug,
template_environment=template_environment,
default_handler_class=Missing404Handler,
report_generator=ReportGenerator(loader),
xsrf_cookies=True,
# COOKIE_SECRET being undefined will result in no login information being
# presented to the user.

View file

@ -1,4 +1,5 @@
import argparse
from pathlib import Path
from tornado.ioloop import IOLoop
@ -10,6 +11,11 @@ def configure_parser(parser):
"--no-debug", help="Don't enable debug mode",
action="store_false",
)
parser.add_argument(
"--theme",
help="A directory containing extensions for templates and static data",
default=None,
)
return parser
@ -17,7 +23,12 @@ def main():
parser = argparse.ArgumentParser()
configure_parser(parser)
args = parser.parse_args()
app = make_app(debug=args.no_debug)
theme_dir = args.theme
if theme_dir is not None:
theme_dir = Path(theme_dir).absolute()
assert theme_dir.exists()
assert (theme_dir / 'templates').exists()
app = make_app(debug=args.no_debug, theme_dir=theme_dir)
app.listen(8080)
IOLoop.instance().start()

View file

@ -2,7 +2,6 @@ import base64
import dataclasses
from datetime import datetime
import io
from pathlib import Path
import typing
import zlib
@ -137,7 +136,7 @@ def plot(times, concentrations, model: models.ExposureModel):
for i, (presence_start, presence_finish) in enumerate(model.exposed.presence.boundaries()):
plt.fill_between(
times, concentrations, 0,
where=(np.array(times)>presence_start) & (np.array(times)<presence_finish),
where=(np.array(times) > presence_start) & (np.array(times) < presence_finish),
color="#1f77b4", alpha=0.1,
label="Presence of exposed person(s)" if i == 0 else ""
)
@ -271,31 +270,43 @@ def comparison_report(scenarios: typing.Dict[str, models.ExposureModel]):
}
def build_report(base_url: str, model: models.ExposureModel, form: FormData):
now = datetime.utcnow().astimezone()
time = now.strftime("%Y-%m-%d %H:%M:%S UTC")
@dataclasses.dataclass
class ReportGenerator:
jinja_loader: jinja2.BaseLoader
context = {
'model': model,
'form': form,
'creation_date': time,
}
def build_report(self, base_url: str, form: FormData) -> str:
model = form.build_model()
context = self.prepare_context(base_url, model, form)
return self.render(context)
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(base_url, form)
def prepare_context(self, base_url: str, model: models.ExposureModel, form: FormData) -> dict:
now = datetime.utcnow().astimezone()
time = now.strftime("%Y-%m-%d %H:%M:%S UTC")
cara_templates = Path(__file__).parent.parent / "templates"
calculator_templates = Path(__file__).parent / "templates"
env = jinja2.Environment(
loader=jinja2.FileSystemLoader([str(cara_templates), str(calculator_templates)]),
undefined=jinja2.StrictUndefined,
)
env.filters['non_zero_percentage'] = non_zero_percentage
env.filters['readable_minutes'] = readable_minutes
env.filters['minutes_to_time'] = minutes_to_time
env.filters['float_format'] = "{0:.2f}".format
env.filters['int_format'] = "{:0.0f}".format
template = env.get_template("report.html.j2")
return template.render(**context)
context = {
'model': model,
'form': form,
'creation_date': time,
}
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(base_url, form)
return context
def _template_environment(self) -> jinja2.Environment:
env = jinja2.Environment(
loader=self.jinja_loader,
undefined=jinja2.StrictUndefined,
)
env.filters['non_zero_percentage'] = non_zero_percentage
env.filters['readable_minutes'] = readable_minutes
env.filters['minutes_to_time'] = minutes_to_time
env.filters['float_format'] = "{0:.2f}".format
env.filters['int_format'] = "{:0.0f}".format
return env
def render(self, context: dict) -> str:
template = self._template_environment().get_template("calculator.report.html.j2")
return template.render(**context)

View file

@ -12,6 +12,8 @@
<body id="body">
{% block report_header %}
<div style="position:relative; text-align:center; margin-left:-200pt; max-height:180pt; margin-bottom: 1em;">
<img src="/static/images/cara_logo_text.png" style="height:150px; display:inline-block; vertical-align:middle; object-fit:cover;">
<h1 style="display:inline; vertical-align:middle; margin-left:1em;">Report</h1>
@ -19,20 +21,19 @@
<p class=subtitle> Created {{ creation_date }} using CARA calculator version v{{ form.calculator_version }}</p><br>
<p><strong>Applicable rules: <br>
Please ensure that this scenario conforms to current <a href="https://hse.cern/covid-19-information"> CERN HSE rules</a> (minimum ventilation requirements, mask wearing and the maximum number of people permitted in a space).</strong> <br>
The results of this simulation are colour coded according to the risk values authorized at CERN (approved in December 2020):
<ul><li>Events with a <span class="green_bkg">P(i) less than 5%</span> may go ahead without further mitigation measures.</li>
<li>Events with a <span class="yellow_bkg">P(i) between 5% and 15%</span> shall be subject to ALARA principles (see footnote) to minimise the risk before proceeding.</li>
<li>Events with a <span class="red_bkg">P(i) exceeding 15% or a number of expected new cases that exceeds 1</span> may not take place until additional measures are in place and a risk reduction has been performed.</li>
</ul>
</p>
{% endblock report_header %}
{% block report_preamble %}
{% endblock report_preamble %}
{% block simulation_overview %}
<p><strong>Simulation:</strong></p>
<p>Simulation Name: {{ form.simulation_name }}</p>
<p>Room Number: {{ form.room_number }}</p>
<p class="data_title">Input data:</p>
<ul>
<li><p class="data_text">Virus variant:
@ -78,9 +79,9 @@
Sliding / Side-Hung</p></li>
{% endif %}
<li><p class="data_subtext">Opening distance: {{ form.opening_distance }} m</p></li>
<li><p class="data_subtext">Windows open:
<li><p class="data_subtext">Windows open:
{% if form.window_opening_regime == "windows_open_periodically" %}
Periodically for {{ form.windows_duration | readable_minutes}}
Periodically for {{ form.windows_duration | readable_minutes}}
every {{ form.windows_frequency | readable_minutes}}
{% elif form.window_opening_regime == "windows_open_permanently" %}
Permanently
@ -148,7 +149,7 @@
{% if form.exposed_lunch_option%}
Yes</li>
<ul>
<li><p class="data_subtext">Start time: {{ form.exposed_lunch_start | minutes_to_time }} &nbsp&nbsp End time: {{ form.exposed_lunch_finish | minutes_to_time }}
<li><p class="data_subtext">Start time: {{ form.exposed_lunch_start | minutes_to_time }} &nbsp&nbsp End time: {{ form.exposed_lunch_finish | minutes_to_time }}</p></li>
</ul>
{% else%}
No
@ -173,7 +174,7 @@
{% if form.infected_lunch_option%}
Yes</li>
<ul>
<li><p class="data_subtext">Start time: {{ form.infected_lunch_start | minutes_to_time }} &nbsp&nbsp End time: {{ form.infected_lunch_finish | minutes_to_time }}
<li><p class="data_subtext">Start time: {{ form.infected_lunch_start | minutes_to_time }} &nbsp&nbsp End time: {{ form.infected_lunch_finish | minutes_to_time }}</p></li>
</ul>
{% else%}
No
@ -192,7 +193,7 @@
</ul>
{% endif %}
<p class="data_title">Mask wearing:</p>
<p class="data_title">Mask wearing:</p>
<ul>
<li><p class="data_text">Masks worn at workstations? {{ 'Yes' if form.mask_wearing_option == "mask_on" else 'No' }} </p></li>
{% if form.mask_wearing_option == "mask_on" %}
@ -201,29 +202,15 @@
</ul>
{% endblock simulation_overview %}
{% block report_results %}
<p class="result_title">Results:</p>
<p class="data_text">
<span
{% if ((prob_inf > 15) or (expected_new_cases >= 1)) %}
class="red_bkg"><strong>Not Acceptable:</strong>
{% elif 5 <= prob_inf <= 15 %}
class="yellow_bkg"><strong> Attention:</strong>
{% elif prob_inf < 5 %}
class="green_bkg">Acceptable:
{% endif %}
</span>
In this scenario, the estimated probability of one exposed occupant getting infected P(i) is {{ prob_inf | non_zero_percentage }} and the expected number of new cases is {{ expected_new_cases | float_format }}.
{% if ((prob_inf > 15) or (expected_new_cases >= 1)) %}
This exceeds the authorised risk threshold or number of expected new cases.
The risk level must be reduced before this activity can be undertaken.
{% elif (5 <= prob_inf <= 15) %}
This activity has an elevated level of risk, ALARA principles must be applied to minimise the level of risk before undertaking the activity.
See the footnotes for more details on the ALARA principles.
{% elif (prob_inf < 5) %}
This level of risk is within acceptable parameters, no further actions are required.
{% endif %}</p>
{% block report_summary %}
In this scenario, the estimated probability of one exposed occupant getting infected P(i) is {{ prob_inf | non_zero_percentage }} and the expected number of new cases is {{ expected_new_cases | float_format }}.
{% endblock report_summary %}
</p>
<p class="data_title">Exposure graph:</p>
<img id="scenario_concentration_plot" src="{{ scenario_plot_src }}">
@ -254,6 +241,8 @@ In this scenario, the estimated probability of one exposed occupant getting infe
<p class="data_title">Alternative scenarios:</p>
<p class="data_text">
<img id="scenario_concentration_plot" src="{{ alternative_scenarios.plot }}" align="left" />
{% block report_scenarios_summary_table %}
<table class="table w-auto">
<thead class="thead-light">
<tr>
@ -264,13 +253,7 @@ In this scenario, the estimated probability of one exposed occupant getting infe
</thead>
<tbody>
{% for scenario_name, scenario_stats in alternative_scenarios.stats.items() %}
{%if (( scenario_stats.probability_of_infection > 15) or (scenario_stats.expected_new_cases >= 1)) %}
<tr class="red_bkg">
{% elif (5 <= scenario_stats.probability_of_infection <= 15) %}
<tr class="yellow_bkg">
{% elif (scenario_stats.probability_of_infection < 5) %}
<tr class="green_bkg">
{% endif%}
<tr>
<td> {{ scenario_name }}</td>
<td> {{ scenario_stats.probability_of_infection | non_zero_percentage }}</td>
<td style="text-align:right">{{ scenario_stats.expected_new_cases | float_format }}</td>
@ -278,6 +261,7 @@ In this scenario, the estimated probability of one exposed occupant getting infe
{% endfor %}
</tbody>
</table>
{% endblock report_scenarios_summary_table %}
</p>
<div style="clear: both;">
@ -291,31 +275,32 @@ In this scenario, the estimated probability of one exposed occupant getting infe
<br>
</p>
<p class="data_text">
<strong> Footnotes for ALARA: </strong><br>
ALARA stands for As Low As Reasonably Achievable. It can be summarised based on 3 main points:
<ol>
<li>Justification - any exposure of persons has to be justified </li>
<li>Limitation - the personal doses have to be kept below the legal limits (in this case the CERN exposure limits)</li>
<li>Optimisation - the personal doses and collective doses have to be kept as low as reasonably achievable (ALARA).</li>
</ol>
For more information, please refer to <a href="https://cds.cern.ch/record/1533023/files/CERN-2013-001-p415.pdf"> this document from CERN HSE </a> and <a href="https://www.cdc.gov/nceh/radiation/safety.html#:~:text=ALARA%20stands%20for%20%E2%80%9Cas%20low,time%2C%20distance%2C%20and%20shielding."> this publication from the CDC.</a>
<br><br>
</p>
{% endblock report_results %}
{% block report_footer %}
<div>
<a href="{{ qr_code.link }}"><img style="width:250pt;" id="qr_code" src="{{ qr_code.image }}"/></a>
<i style="position:absolute; margin-top:3.5cm">Click the QR code to regenerate the report and get a shareable link.<br>Alternatively, scan to regenerate the report.<br> Mobile-friendly app coming soon!</i>
<a href="{{ qr_code.link }}" style="float: left;"><img style="width:250pt;" id="qr_code" src="{{ qr_code.image }}"/></a>
<span style="float: left; min-height: 250pt; line-height: 250pt; vertical-align: middle; display: inline-block;">
<p style="display: inline-block; vertical-align: middle; line-height: normal;">
Click the QR code to regenerate the report and get a shareable link.<br>Alternatively, scan to regenerate the report.<br> Mobile-friendly app coming soon!
</p>
</span>
</div>
<div style="clear: both;"></div>
{% block disclaimer_container %}
<br><br><br>
<div style="border: 2px solid black; padding: 15px;">
{% block disclaimer %}
<p class="image"> <img align="middle" src="/calculator/static/images/disclaimer.jpg" width="40" height="40"><b>Disclaimer:</b><br><br></p>
<p class="discalimer">The risk assessment tool simulates the long-range airborne spread SARS-CoV-2 virus in a finite volume, assuming a homogenous mixture, and estimates the risk of COVID-19 infection therein. The results DO NOT include short-range airborne exposure (where the physical distance plays a factor) nor the other known modes of SARS-CoV-2 transmission. Hence, the output from this model is only valid when the other recommended public health & safety instructions are observed, such as adequate physical distancing, good hand hygiene and other barrier measures.<br><br>
The model used is based on scientific publications relating to airborne transmission of infectious diseases, dose-response exposures and aerosol science, as of December 2020 . It can be used to compare the effectiveness of different airborne-related risk mitigation measures.<br><br>
Note that this model applies a deterministic approach, i.e., it is assumed at least one person is infected and shedding viruses into the simulated volume. Nonetheless, it is also important to understand that the absolute risk of infection is uncertain, as it will depend on the probability that someone infected attends the event. The model is most useful for comparing the impact and effectiveness of different mitigation measures such as ventilation, filtration, exposure time, physical activity and the size of the room, only considering long-range airborne transmission of COVID-19 in indoor settings.<br><br>
This tool is designed to be informative, allowing the user to adapt different settings and model the relative impact on the estimated infection probabilities. The objective is to facilitate targeted decision-making and investment through comparisons, rather than a singular determination of absolute risk. While the SARS-CoV-2 virus is in circulation among the population, the notion of 'zero risk' or a 'completely safe scenario' does not exist. Each event modelled is unique and the results generated therein are only as accurate as the inputs and assumptions.<br><br>
CARA is made available for internal CERN use only. It is intended for Members of Personnel with roles related to Supervision, Health & Safety or Space Management, in order to simulate the concerned workplaces on CERN sites. For use outside of this scope, it has to be performed under a pre-defined framework and licence agreement issued by CERN Knowledge Transfer (<a href="mailto:kt@cern.ch">kt@cern.ch</a>).</p>
This tool is designed to be informative, allowing the user to adapt different settings and model the relative impact on the estimated infection probabilities. The objective is to facilitate targeted decision-making and investment through comparisons, rather than a singular determination of absolute risk. While the SARS-CoV-2 virus is in circulation among the population, the notion of 'zero risk' or a 'completely safe scenario' does not exist. Each event modelled is unique and the results generated therein are only as accurate as the inputs and assumptions.
</p>
{% endblock disclaimer %}
</div>
{% endblock disclaimer_container %}
{% endblock report_footer %}
</body>
</html>

View file

@ -0,0 +1,2 @@
{# The main calculator report, this template is intended to be implemented by themes #}
{% extends "base/calculator.report.html.j2" %}

View file

@ -0,0 +1,96 @@
{% extends "base/calculator.report.html.j2" %}
{% block report_preamble %}
<p><strong>Applicable rules: <br>
Please ensure that this scenario conforms to current <a href="https://hse.cern/covid-19-information"> CERN HSE rules</a> (minimum ventilation requirements, mask wearing and the maximum number of people permitted in a space).</strong> <br>
The results of this simulation are colour coded according to the risk values authorized at CERN (approved in December 2020):
<ul><li>Events with a <span class="green_bkg">P(i) less than 5%</span> may go ahead without further mitigation measures.</li>
<li>Events with a <span class="yellow_bkg">P(i) between 5% and 15%</span> shall be subject to ALARA principles (see footnote) to minimise the risk before proceeding.</li>
<li>Events with a <span class="red_bkg">P(i) exceeding 15% or a number of expected new cases that exceeds 1</span> may not take place until additional measures are in place and a risk reduction has been performed.</li>
</ul>
</p>
{% endblock report_preamble %}
{% block report_summary %}
<span
{% if ((prob_inf > 15) or (expected_new_cases >= 1)) %}
class="red_bkg"><strong>Not Acceptable:</strong>
{% elif 5 <= prob_inf <= 15 %}
class="yellow_bkg"><strong> Attention:</strong>
{% elif prob_inf < 5 %}
class="green_bkg">Acceptable:
{% endif %}
</span>
{{ super() }}
{% if ((prob_inf > 15) or (expected_new_cases >= 1)) %}
This exceeds the authorised risk threshold or number of expected new cases.
The risk level must be reduced before this activity can be undertaken.
{% elif (5 <= prob_inf <= 15) %}
This activity has an elevated level of risk, ALARA principles must be applied to minimise the level of risk before undertaking the activity.
See the footnotes for more details on the ALARA principles.
{% elif (prob_inf < 5) %}
This level of risk is within acceptable parameters, no further actions are required.
{% endif %}
{% endblock report_summary %}
{% block report_scenarios_summary_table %}
<table class="table w-auto">
<thead class="thead-light">
<tr>
<th>Scenario</th>
<th>P(i)</th>
<th>Expected new cases</th>
</tr>
</thead>
<tbody>
{% for scenario_name, scenario_stats in alternative_scenarios.stats.items() %}
{%if (( scenario_stats.probability_of_infection > 15) or (scenario_stats.expected_new_cases >= 1)) %}
<tr class="red_bkg">
{% elif (5 <= scenario_stats.probability_of_infection <= 15) %}
<tr class="yellow_bkg">
{% elif (scenario_stats.probability_of_infection < 5) %}
<tr class="green_bkg">
{% endif%}
<td> {{ scenario_name }}</td>
<td> {{ scenario_stats.probability_of_infection | non_zero_percentage }}</td>
<td style="text-align:right">{{ scenario_stats.expected_new_cases | float_format }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock report_scenarios_summary_table %}
{% block report_footer %}
<p class="data_text">
<strong> Footnotes for ALARA: </strong><br>
ALARA stands for As Low As Reasonably Achievable. It can be summarised based on 3 main points:
<ol>
<li>Justification - any exposure of persons has to be justified </li>
<li>Limitation - the personal doses have to be kept below the legal limits (in this case the CERN exposure limits)</li>
<li>Optimisation - the personal doses and collective doses have to be kept as low as reasonably achievable (ALARA).</li>
</ol>
For more information, please refer to <a href="https://cds.cern.ch/record/1533023/files/CERN-2013-001-p415.pdf"> this document from CERN HSE </a> and <a href="https://www.cdc.gov/nceh/radiation/safety.html#:~:text=ALARA%20stands%20for%20%E2%80%9Cas%20low,time%2C%20distance%2C%20and%20shielding."> this publication from the CDC.</a>
<br><br>
</p>
{{ super() }}
{% endblock report_footer %}
{% block disclaimer %}
{{ super() }}
<p>
CARA is made available for internal CERN use only.
It is intended for Members of Personnel with roles related to Supervision, Health & Safety or Space Management, in order to simulate the concerned workplaces on CERN sites.
For use outside of this scope, it has to be performed under a pre-defined framework and licence agreement issued by CERN Knowledge Transfer (<a href="mailto:kt@cern.ch">kt@cern.ch</a>).
</p>
{% endblock disclaimer %}
</body>
</html>

View file

@ -3,6 +3,7 @@ import time
import pytest
from cara.apps.calculator import report_generator
from cara.apps.calculator import make_app
def test_generate_report(baseline_form):
@ -13,8 +14,9 @@ def test_generate_report(baseline_form):
time_limit: float = 5.0 # seconds
start = time.perf_counter()
model = baseline_form.build_model()
report = report_generator.build_report("", model, baseline_form)
generator: report_generator.ReportGenerator = make_app().settings['report_generator']
report = generator.build_report("", baseline_form)
end = time.perf_counter()
assert report != ""
assert end - start < time_limit

View file

@ -1,12 +1,15 @@
import pytest
from pathlib import Path
from cara.apps.calculator import make_app
import pytest
import tornado.testing
import cara.apps.calculator
from cara.apps.calculator.report_generator import generate_qr_code
@pytest.fixture
def app():
return make_app()
return cara.apps.calculator.make_app()
async def test_homepage(http_server_client):
@ -14,7 +17,7 @@ async def test_homepage(http_server_client):
assert response.code == 200
async def test_calculator(http_server_client):
async def test_calculator_form(http_server_client):
# Both with and without a trailing slash.
response = await http_server_client.fetch('/calculator')
assert response.code == 200
@ -23,6 +26,29 @@ async def test_calculator(http_server_client):
assert response.code == 200
class TestBasicApp(tornado.testing.AsyncHTTPTestCase):
def get_app(self):
return cara.apps.calculator.make_app()
def test_report(self):
response = self.fetch('/calculator/baseline-model/result')
self.assertEqual(response.code, 200)
assert 'CERN HSE rules' not in response.body.decode()
assert 'the expected number of new cases is' in response.body.decode()
class TestCernApp(tornado.testing.AsyncHTTPTestCase):
def get_app(self):
cern_theme = Path(cara.apps.calculator.__file__).parent / 'themes' / 'cern'
return cara.apps.calculator.make_app(theme_dir=cern_theme)
def test_report(self):
response = self.fetch('/calculator/baseline-model/result')
self.assertEqual(response.code, 200)
assert 'CERN HSE rules' in response.body.decode()
assert 'the expected number of new cases is' in response.body.decode()
async def test_qrcode_urls(http_server_client, baseline_form):
prefix = 'proto://hostname/prefix'
qr_data = generate_qr_code(prefix, baseline_form)

View file

@ -76,6 +76,7 @@ setup(
'apps/calculator/templates/*.j2',
'apps/calculator/*',
'apps/calculator/*/*',
'apps/calculator/*/*/*'
'apps/calculator/*/*/*',
'apps/calculator/*/*/*/*',
]},
)