From 9a17121d9e6a787c163f6d644ff46014f53ce7b3 Mon Sep 17 00:00:00 2001
From: Phil Elson
Date: Thu, 15 Apr 2021 11:36:18 +0200
Subject: [PATCH 1/3] Allow theming of CARA (initially in the report only, to
be extended later on).
---
README.md | 6 +
app.sh | 15 ++-
cara/apps/calculator/__init__.py | 27 +++--
cara/apps/calculator/__main__.py | 13 ++-
cara/apps/calculator/report_generator.py | 65 ++++++-----
.../calculator.report.html.j2} | 95 +++++-----------
.../templates/calculator.report.html.j2 | 2 +
.../cern/templates/calculator.report.html.j2 | 107 ++++++++++++++++++
.../apps/calculator/test_report_generator.py | 6 +-
cara/tests/apps/calculator/test_webapp.py | 34 +++++-
setup.py | 3 +-
11 files changed, 259 insertions(+), 114 deletions(-)
rename cara/apps/calculator/templates/{report.html.j2 => base/calculator.report.html.j2} (62%)
create mode 100644 cara/apps/calculator/templates/calculator.report.html.j2
create mode 100644 cara/apps/calculator/themes/cern/templates/calculator.report.html.j2
diff --git a/README.md b/README.md
index c7915e86..be1f81f9 100644
--- a/README.md
+++ b/README.md
@@ -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
```
diff --git a/app.sh b/app.sh
index a9fc0e45..1aa80c4a 100755
--- a/app.sh
+++ b/app.sh
@@ -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': '*'}"
diff --git a/cara/apps/calculator/__init__.py b/cara/apps/calculator/__init__.py
index 9690fed7..8779270b 100644
--- a/cara/apps/calculator/__init__.py
+++ b/cara/apps/calculator/__init__.py
@@ -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.
diff --git a/cara/apps/calculator/__main__.py b/cara/apps/calculator/__main__.py
index 55c134f7..1b9bcce2 100644
--- a/cara/apps/calculator/__main__.py
+++ b/cara/apps/calculator/__main__.py
@@ -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()
diff --git a/cara/apps/calculator/report_generator.py b/cara/apps/calculator/report_generator.py
index 8effd90c..8e3691f5 100644
--- a/cara/apps/calculator/report_generator.py
+++ b/cara/apps/calculator/report_generator.py
@@ -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_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)
diff --git a/cara/apps/calculator/templates/report.html.j2 b/cara/apps/calculator/templates/base/calculator.report.html.j2
similarity index 62%
rename from cara/apps/calculator/templates/report.html.j2
rename to cara/apps/calculator/templates/base/calculator.report.html.j2
index 148e44fd..9dac5c2e 100644
--- a/cara/apps/calculator/templates/report.html.j2
+++ b/cara/apps/calculator/templates/base/calculator.report.html.j2
@@ -12,27 +12,23 @@
-
-

-
Report
-
+{% block report_header %}
Created {{ creation_date }} using CARA calculator version v{{ form.calculator_version }}
- Applicable rules:
- Please ensure that this scenario conforms to current CERN HSE rules (minimum ventilation requirements, mask wearing and the maximum number of people permitted in a space).
- The results of this simulation are colour coded according to the risk values authorized at CERN (approved in December 2020):
-
- Events with a P(i) less than 5% may go ahead without further mitigation measures.
- - Events with a P(i) between 5% and 15% shall be subject to ALARA principles (see footnote) to minimise the risk before proceeding.
- - Events with a P(i) exceeding 15% or a number of expected new cases that exceeds 1 may not take place until additional measures are in place and a risk reduction has been performed.
-
-
+{% endblock report_header %}
+
+{% block report_preamble %}
+
+{% endblock report_preamble %}
+
+{% block simulation_overview %}
Simulation:
Simulation Name: {{ form.simulation_name }}
Room Number: {{ form.room_number }}
-
+
Input data:
Virus variant:
@@ -78,9 +74,9 @@
Sliding / Side-Hung
{% endif %}
Opening distance: {{ form.opening_distance }} m
- Windows open:
+
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
@@ -192,7 +188,7 @@
{% endif %}
- Mask wearing:
+ Mask wearing:
+{% endblock simulation_overview %}
+
+{% block report_results %}
Results:
- 15) or (expected_new_cases >= 1)) %}
- class="red_bkg">Not Acceptable:
-{% elif 5 <= prob_inf <= 15 %}
- class="yellow_bkg"> Attention:
-{% elif prob_inf < 5 %}
- class="green_bkg">Acceptable:
-{% endif %}
-
-
-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 %}
+ {% 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 %}
+
Exposure graph:
@@ -254,6 +236,8 @@ In this scenario, the estimated probability of one exposed occupant getting infe
Alternative scenarios:
+
+ {% block report_scenarios_summary_table %}
@@ -264,13 +248,7 @@ In this scenario, the estimated probability of one exposed occupant getting infe
{% for scenario_name, scenario_stats in alternative_scenarios.stats.items() %}
- {%if (( scenario_stats.probability_of_infection > 15) or (scenario_stats.expected_new_cases >= 1)) %}
-
- {% elif (5 <= scenario_stats.probability_of_infection <= 15) %}
-
- {% elif (scenario_stats.probability_of_infection < 5) %}
-
- {% endif%}
+
| {{ scenario_name }} |
{{ scenario_stats.probability_of_infection | non_zero_percentage }} |
{{ scenario_stats.expected_new_cases | float_format }} |
@@ -278,6 +256,7 @@ In this scenario, the estimated probability of one exposed occupant getting infe
{% endfor %}
+ {% endblock report_scenarios_summary_table %}
@@ -291,31 +270,9 @@ In this scenario, the estimated probability of one exposed occupant getting infe
-
- Footnotes for ALARA:
- ALARA stands for As Low As Reasonably Achievable. It can be summarised based on 3 main points:
-
- - Justification - any exposure of persons has to be justified
- - Limitation - the personal doses have to be kept below the legal limits (in this case the CERN exposure limits)
- - Optimisation - the personal doses and collective doses have to be kept as low as reasonably achievable (ALARA).
-
- For more information, please refer to
this document from CERN HSE and
this publication from the CDC.
-
-
+{% endblock report_results %}
-
-

-
Click the QR code to regenerate the report and get a shareable link.
Alternatively, scan to regenerate the report.
Mobile-friendly app coming soon!
-
-
-
-
-
Disclaimer:
-
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.
- 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.
- 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.
- 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.
- 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 (kt@cern.ch).
-
+{% block report_footer %}
+{% endblock report_footer %}