Automatic report regeneration
This commit is contained in:
parent
1ff9a4d352
commit
2997607e60
11 changed files with 186 additions and 127 deletions
|
|
@ -33,7 +33,7 @@ A room number is included, if you do not wish to use a formal room number any re
|
|||
|
||||
### Room Data
|
||||
|
||||
Please enter either the room volume (in m<sup>3</sup>) or both the floor area (m<sup>2</sup>) and the room height (m).
|
||||
Please enter either the room volume (in m³) or both the floor area (m²) and the room height (m).
|
||||
This information is available via GIS Portal (https://gis.cern.ch/gisportal/).
|
||||
|
||||
### Ventilation type
|
||||
|
|
@ -99,7 +99,6 @@ As an example, for a shared office with 4 people, where one person is infected,
|
|||
There are a few predefined activities in the tool at present.
|
||||
|
||||
**Office ** = All persons seated, talking occasionally (1/3rd of the time, with normal breathing the other 2/3rds of the time). Everyone (exposed and infected occupants) is treated the same in this model.
|
||||
|
||||
**Meeting** = All persons seated, having a conversation (approximately each occupant is 1/N % of the time talking, where N is the number of occupants). Everyone (exposed and infected occupants) is treated the same in this model.
|
||||
|
||||
**Library** = All persons seated, breathing only (not talking), all the time.
|
||||
|
|
@ -191,9 +190,8 @@ It contains a summary of all the input data, which will allow the simulation to
|
|||
|
||||
This part of the report shows the ``P(i)`` or probability of one exposed person getting infected.
|
||||
It is estimated based on the emission rate of virus into the simulated volume, and the amount which is inhaled by exposed individuals.
|
||||
This probability is valid for the simulation duration - i.e. if you have simulated one day and plan to work 5 days in these conditions and the infected person emits the same amount of virus each day, the cumulative probability of infection is ``(1-(1-P(i))^5)```.
|
||||
This probability is valid for the simulation duration - i.e. if you have simulated one day and plan to work 5 days in these conditions and the infected person emits the same amount of virus each day, the cumulative probability of infection is ``(1-(1-P(i))^5)``.
|
||||
If you are using the natural ventilation option, the simulation is only valid for the selected month, because the following or preceding month will have a different average temperature profile.
|
||||
|
||||
The ``expected number of new cases`` for the simulation is calculated based on the probability of infection, multiplied by the number of exposed occupants.
|
||||
|
||||
### Exposure graph
|
||||
|
|
@ -207,6 +205,13 @@ It is determined by:
|
|||
* Under natural ventilation conditions, the effectiveness of ventilation relies upon the hourly temperature difference between the inside and outside air temperature.
|
||||
* A HEPA filter removes infectious quanta from the air at a constant rate and is modelled in the same way as mechanical ventilation, however air passed through a HEPA filter is recycled (i.e. it is not fresh air).
|
||||
|
||||
### QR code
|
||||
|
||||
At the end of the report you can find a unique QR code / hyperlink for this report. This provides an automatic way to review the calculator form with the corresponding specified parameters.
|
||||
This allows for:
|
||||
* sharing reports by either scanning or clicking on the QR code to obtain a shareable link.
|
||||
* easily regenerating reports with any new versions of the CARA model released in the future.
|
||||
|
||||
# Conclusion
|
||||
|
||||
This tool provides informative comparisons for COVID-19 (long-range) airborne risk only - see Disclaimer
|
||||
|
|
|
|||
|
|
@ -55,7 +55,8 @@ class ConcentrationModel(BaseRequestHandler):
|
|||
self.finish(json.dumps(response_json))
|
||||
return
|
||||
|
||||
report = build_report(form.build_model(), form)
|
||||
base_url = self.request.protocol + "://" + self.request.host
|
||||
report = build_report(base_url, form.build_model(), form)
|
||||
self.finish(report)
|
||||
|
||||
|
||||
|
|
@ -63,7 +64,8 @@ class StaticModel(BaseRequestHandler):
|
|||
def get(self):
|
||||
form = model_generator.FormData.from_dict(model_generator.baseline_raw_form_data())
|
||||
model = form.build_model()
|
||||
report = build_report(model, form)
|
||||
base_url = self.request.protocol + "://" + self.request.host
|
||||
report = build_report(base_url, model, form)
|
||||
self.finish(report)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -82,66 +82,29 @@ class FormData:
|
|||
if value == "":
|
||||
form_data[key] = "0"
|
||||
|
||||
time_attributes = [
|
||||
'exposed_lunch_start', 'exposed_lunch_finish', 'exposed_start', 'exposed_finish',
|
||||
'infected_lunch_start', 'infected_lunch_finish', 'infected_start', 'infected_finish',
|
||||
]
|
||||
for attr_name in time_attributes:
|
||||
for attr_name in BOOLEAN_ATTRIBUTES:
|
||||
form_data[attr_name] = form_data[attr_name] == '1'
|
||||
for attr_name in FLOAT_ATTRIBUTES:
|
||||
form_data[attr_name] = float(form_data[attr_name])
|
||||
for attr_name in INT_ATTRIBUTES:
|
||||
form_data[attr_name] = int(form_data[attr_name])
|
||||
for attr_name in TIME_ATTRIBUTES:
|
||||
form_data[attr_name] = time_string_to_minutes(form_data[attr_name])
|
||||
|
||||
boolean_attributes = [
|
||||
'hepa_option', 'exposed_lunch_option', 'infected_lunch_option', 'infected_dont_have_breaks_with_exposed',
|
||||
]
|
||||
for attr_name in boolean_attributes:
|
||||
form_data[attr_name] = form_data[attr_name] == '1'
|
||||
|
||||
instance = cls(
|
||||
activity_type=form_data['activity_type'],
|
||||
air_changes=float(form_data['air_changes']),
|
||||
air_supply=float(form_data['air_supply']),
|
||||
ceiling_height=float(form_data['ceiling_height']),
|
||||
exposed_coffee_break_option=form_data['exposed_coffee_break_option'],
|
||||
exposed_coffee_duration=int(form_data['exposed_coffee_duration']),
|
||||
exposed_finish=form_data['exposed_finish'],
|
||||
exposed_lunch_finish=form_data['exposed_lunch_finish'],
|
||||
exposed_lunch_option=form_data['exposed_lunch_option'],
|
||||
exposed_lunch_start=form_data['exposed_lunch_start'],
|
||||
exposed_start=form_data['exposed_start'],
|
||||
floor_area=float(form_data['floor_area']),
|
||||
hepa_amount=float(form_data['hepa_amount']),
|
||||
hepa_option=form_data['hepa_option'],
|
||||
infected_coffee_break_option=form_data['infected_coffee_break_option'],
|
||||
infected_coffee_duration=int(form_data['infected_coffee_duration']),
|
||||
infected_dont_have_breaks_with_exposed=form_data['infected_dont_have_breaks_with_exposed'],
|
||||
infected_finish=form_data['infected_finish'],
|
||||
infected_lunch_finish=form_data['infected_lunch_finish'],
|
||||
infected_lunch_option=form_data['infected_lunch_option'],
|
||||
infected_lunch_start=form_data['infected_lunch_start'],
|
||||
infected_people=int(form_data['infected_people']),
|
||||
infected_start=form_data['infected_start'],
|
||||
mask_type=form_data['mask_type'],
|
||||
mask_wearing_option=form_data['mask_wearing_option'],
|
||||
mechanical_ventilation_type=form_data['mechanical_ventilation_type'],
|
||||
model_version=form_data['model_version'],
|
||||
opening_distance=float(form_data['opening_distance']),
|
||||
event_month=form_data['event_month'],
|
||||
room_number=form_data['room_number'],
|
||||
room_volume=float(form_data['room_volume']),
|
||||
simulation_name=form_data['simulation_name'],
|
||||
total_people=int(form_data['total_people']),
|
||||
ventilation_type=form_data['ventilation_type'],
|
||||
volume_type=form_data['volume_type'],
|
||||
windows_duration=float(form_data['windows_duration']),
|
||||
windows_frequency=float(form_data['windows_frequency']),
|
||||
window_height=float(form_data['window_height']),
|
||||
window_type=form_data['window_type'],
|
||||
window_width=float(form_data['window_width']),
|
||||
windows_number=int(form_data['windows_number']),
|
||||
window_opening_regime=form_data['window_opening_regime'],
|
||||
)
|
||||
form_data.pop('_xsrf', None)
|
||||
instance = cls(**form_data)
|
||||
instance.validate()
|
||||
return instance
|
||||
|
||||
@classmethod
|
||||
def to_dict(self, form: "FormData") -> dict:
|
||||
form_dict = form.__dict__.copy()
|
||||
for attr_name in TIME_ATTRIBUTES:
|
||||
form_dict[attr_name] = time_minutes_to_string(form_dict[attr_name])
|
||||
for attr_name in BOOLEAN_ATTRIBUTES:
|
||||
form_dict[attr_name] = form_dict[attr_name] & 1
|
||||
return form_dict
|
||||
|
||||
def validate(self):
|
||||
# Validate time intervals selected by user
|
||||
time_intervals = [
|
||||
|
|
@ -604,6 +567,12 @@ WINDOWS_TYPES = {'window_sliding', 'window_hinged', 'not-applicable'}
|
|||
|
||||
COFFEE_OPTIONS_INT = {'coffee_break_0':0, 'coffee_break_1':1, 'coffee_break_2':2, 'coffee_break_4':4}
|
||||
|
||||
BOOLEAN_ATTRIBUTES = {'hepa_option', 'exposed_lunch_option', 'infected_lunch_option', 'infected_dont_have_breaks_with_exposed'}
|
||||
FLOAT_ATTRIBUTES = {'air_changes', 'air_supply', 'ceiling_height', 'floor_area', 'hepa_amount', 'opening_distance',
|
||||
'room_volume', 'windows_duration', 'windows_frequency', 'window_height', 'window_width'}
|
||||
INT_ATTRIBUTES = {'exposed_coffee_duration', 'infected_coffee_duration', 'infected_people', 'total_people', 'windows_number'}
|
||||
TIME_ATTRIBUTES = {'exposed_lunch_start', 'exposed_lunch_finish', 'exposed_start', 'exposed_finish',
|
||||
'infected_lunch_start', 'infected_lunch_finish', 'infected_start', 'infected_finish'}
|
||||
|
||||
def time_string_to_minutes(time: str) -> minutes_since_midnight:
|
||||
"""
|
||||
|
|
@ -612,3 +581,11 @@ def time_string_to_minutes(time: str) -> minutes_since_midnight:
|
|||
:return: The number of minutes between 'time' and 00:00
|
||||
"""
|
||||
return minutes_since_midnight(60 * int(time[:2]) + int(time[3:]))
|
||||
|
||||
def time_minutes_to_string(time: int) -> str:
|
||||
"""
|
||||
Converts time from an integer number of minutes after 00:00 to string-format
|
||||
:param time: The number of minutes between 'time' and 00:00
|
||||
:return: A string of the form "HH:MM" representing a time of day
|
||||
"""
|
||||
return "{0:0=2d}".format(int(time/60)) + ":" + "{0:0=2d}".format(time%60)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import io
|
|||
from pathlib import Path
|
||||
import typing
|
||||
|
||||
import qrcode
|
||||
import urllib
|
||||
import jinja2
|
||||
import matplotlib
|
||||
matplotlib.use('agg')
|
||||
|
|
@ -61,16 +63,46 @@ def calculate_report_data(model: models.ExposureModel):
|
|||
"emission_rate": er,
|
||||
"exposed_occupants": exposed_occupants,
|
||||
"expected_new_cases": expected_new_cases,
|
||||
"scenario_plot_src": embed_figure(plot(times, concentrations, model)),
|
||||
"scenario_plot_src": img2base64(_figure2bytes(plot(times, concentrations, model))),
|
||||
"repeated_events": repeated_events,
|
||||
}
|
||||
|
||||
|
||||
def embed_figure(figure) -> str:
|
||||
# Draw the scenario graph.
|
||||
img_data = io.BytesIO()
|
||||
def generate_qr_code(prefix, form: FormData):
|
||||
form_dict = FormData.to_dict(form)
|
||||
url = prefix + "?" + urllib.parse.urlencode(form_dict)
|
||||
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_H,
|
||||
box_size=10,
|
||||
border=4,
|
||||
)
|
||||
qr.add_data(url)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color="black", back_color="white").convert('RGB')
|
||||
|
||||
return {
|
||||
'image': img2base64(_img2bytes(img)),
|
||||
'link': url,
|
||||
}
|
||||
|
||||
|
||||
def _img2bytes(figure):
|
||||
# Draw the image
|
||||
img_data = io.BytesIO()
|
||||
figure.save(img_data, format='png', bbox_inches="tight")
|
||||
return img_data
|
||||
|
||||
|
||||
def _figure2bytes(figure):
|
||||
# Draw the image
|
||||
img_data = io.BytesIO()
|
||||
figure.savefig(img_data, format='png', bbox_inches="tight")
|
||||
return img_data
|
||||
|
||||
|
||||
def img2base64(img_data) -> str:
|
||||
plt.close()
|
||||
img_data.seek(0)
|
||||
pic_hash = base64.b64encode(img_data.read()).decode('ascii')
|
||||
|
|
@ -220,12 +252,12 @@ def comparison_report(scenarios: typing.Dict[str, models.ExposureModel]):
|
|||
'expected_new_cases': model.expected_new_cases(),
|
||||
}
|
||||
return {
|
||||
'plot': embed_figure(comparison_plot(scenarios)),
|
||||
'plot': img2base64(_figure2bytes(comparison_plot(scenarios))),
|
||||
'stats': statistics,
|
||||
}
|
||||
|
||||
|
||||
def build_report(model: models.ExposureModel, form: FormData):
|
||||
def build_report(base_url: str, model: models.ExposureModel, form: FormData):
|
||||
now = datetime.now()
|
||||
time = now.strftime("%d/%m/%Y %H:%M:%S")
|
||||
request = {"the": "form", "request": "data"}
|
||||
|
|
@ -240,6 +272,7 @@ def build_report(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)
|
||||
|
||||
cara_templates = Path(__file__).parent.parent / "templates"
|
||||
calculator_templates = Path(__file__).parent / "templates"
|
||||
|
|
|
|||
|
|
@ -231,7 +231,7 @@ function on_ventilation_type_change() {
|
|||
unrequire_fields(this);
|
||||
|
||||
//Clear invalid inputs for this newly hidden child element
|
||||
removeInvalid(getChildElement($(this)).find('input').not('input[type=radio]').attr('id'));
|
||||
removeInvalid("#"+getChildElement($(this)).find('input').not('input[type=radio]').attr('id'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -456,6 +456,32 @@ function parseTimeToMins(cTime) {
|
|||
|
||||
/* -------On Load------- */
|
||||
$(document).ready(function () {
|
||||
|
||||
//Pre-fill form with known values
|
||||
(new URL(decodeURIComponent(window.location.href))).searchParams.forEach((value, name) => {
|
||||
|
||||
//If element exists
|
||||
if(document.getElementsByName(name).length > 0) {
|
||||
var elemObj = document.getElementsByName(name)[0];
|
||||
|
||||
//Pre-select checked radios
|
||||
if (elemObj.type === 'radio') {
|
||||
if (value !== 'not-applicable') {
|
||||
$('[name="'+name+'"][value="'+value+'"]').prop('checked',true);
|
||||
}
|
||||
}
|
||||
//Pre-select checkboxes
|
||||
else if (elemObj.type === 'checkbox') {
|
||||
elemObj.checked = (value==1);
|
||||
}
|
||||
//Ignore 0 (default) values from server side
|
||||
else if (!(elemObj.classList.contains("non_zero") || elemObj.classList.contains("remove_zero")) || (value != "0.0" && value != "0")) {
|
||||
elemObj.value = value;
|
||||
validateValue(elemObj);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// When the document is ready, deal with the fact that we may be here
|
||||
// as a result of a forward/back browser action. If that is the case, update
|
||||
// the visibility of some of our inputs.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{% extends "layout.html.j2" %}
|
||||
|
||||
{% set MODEL_VERSION="v1.4.0" %}
|
||||
{% set MODEL_VERSION="v1.4.1" %}
|
||||
{% set DEBUG=False %}
|
||||
{% set active_page="calculator/" %}
|
||||
|
||||
|
|
@ -92,8 +92,8 @@
|
|||
<label for="windows_open_permanently">Permanently</label><br>
|
||||
<span class="tabbed"><input type="radio" id="windows_open_periodically" name="window_opening_regime" value="windows_open_periodically" onclick="require_fields(this)"></span>
|
||||
<label for="windows_open_periodically">Periodically:</label>
|
||||
<input type="number" step="any" id="windows_duration" class="disabled" name="windows_duration" placeholder="Duration (min)" min="1" data-has-radio="#windows_open_periodically"> /
|
||||
<input type="number" step="any" id="windows_frequency" class="disabled" name="windows_frequency" placeholder="Frequency (min)" min="1" data-has-radio="#windows_open_periodically">
|
||||
<input type="number" step="any" id="windows_duration" class="non_zero disabled" name="windows_duration" placeholder="Duration (min)" min="1" data-has-radio="#windows_open_periodically"> /
|
||||
<input type="number" step="any" id="windows_frequency" class="non_zero disabled" name="windows_frequency" placeholder="Frequency (min)" min="1" data-has-radio="#windows_open_periodically">
|
||||
<br>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -265,7 +265,6 @@ In this scenario, the estimated probability of one exposed occupant getting infe
|
|||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</p>
|
||||
<div style="clear: both;">
|
||||
|
||||
|
|
@ -279,7 +278,7 @@ In this scenario, the estimated probability of one exposed occupant getting infe
|
|||
<br>
|
||||
</p>
|
||||
|
||||
<p class="data_text">
|
||||
<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>
|
||||
|
|
@ -287,11 +286,15 @@ In this scenario, the estimated probability of one exposed occupant getting infe
|
|||
<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>
|
||||
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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<br><br><br>
|
||||
<div style="border: 2px solid black; padding: 15px;">
|
||||
<p class="image"> <img <img align="middle" src="/calculator/static/images/disclaimer.jpg" width="40" height="40"><b>Disclaimer:</b><br><br></p>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ def baseline_form(baseline_form_data):
|
|||
def test_generate_report(baseline_form):
|
||||
model = baseline_form.build_model()
|
||||
|
||||
report = report_generator.build_report(model, baseline_form)
|
||||
report = report_generator.build_report("", model, baseline_form)
|
||||
assert report != ""
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,64 +3,73 @@
|
|||
# pip freeze | grep -v cara >> requirements.txt
|
||||
|
||||
.[app]
|
||||
anyio==2.1.0
|
||||
appnope==0.1.2
|
||||
argon2-cffi==20.1.0
|
||||
async-generator==1.10
|
||||
attrs==20.2.0
|
||||
attrs==20.3.0
|
||||
backcall==0.2.0
|
||||
bleach==3.2.1
|
||||
certifi==2020.6.20
|
||||
cffi==1.14.3
|
||||
bleach==3.3.0
|
||||
certifi==2020.12.5
|
||||
cffi==1.14.5
|
||||
contextvars==2.4
|
||||
cycler==0.10.0
|
||||
dataclasses==0.7
|
||||
dataclasses==0.8
|
||||
decorator==4.4.2
|
||||
defusedxml==0.6.0
|
||||
entrypoints==0.3
|
||||
importlib-metadata==2.0.0
|
||||
ipykernel==5.3.4
|
||||
ipympl==0.5.8
|
||||
idna==3.1
|
||||
immutables==0.15
|
||||
importlib-metadata==3.5.0
|
||||
ipykernel==5.5.0
|
||||
ipympl==0.6.3
|
||||
ipython==7.16.1
|
||||
ipython-genutils==0.2.0
|
||||
ipywidgets==7.5.1
|
||||
jedi==0.17.2
|
||||
Jinja2==2.11.2
|
||||
ipywidgets==7.6.3
|
||||
jedi==0.18.0
|
||||
Jinja2==2.11.3
|
||||
jsonschema==3.2.0
|
||||
jupyter-client==6.1.7
|
||||
jupyter-core==4.6.3
|
||||
jupyter-server==1.0.5
|
||||
jupyter-client==6.1.11
|
||||
jupyter-core==4.7.1
|
||||
jupyter-server==1.4.1
|
||||
jupyterlab-pygments==0.1.2
|
||||
kiwisolver==1.2.0
|
||||
jupyterlab-widgets==1.0.0
|
||||
kiwisolver==1.3.1
|
||||
MarkupSafe==1.1.1
|
||||
matplotlib==3.3.2
|
||||
matplotlib==3.3.4
|
||||
mistune==0.8.4
|
||||
nbclient==0.5.1
|
||||
nbclient==0.5.2
|
||||
nbconvert==6.0.7
|
||||
nbformat==5.0.8
|
||||
nest-asyncio==1.4.1
|
||||
notebook==6.1.4
|
||||
numpy==1.19.2
|
||||
packaging==20.4
|
||||
pandocfilters==1.4.2
|
||||
parso==0.7.1
|
||||
nbformat==5.1.2
|
||||
nest-asyncio==1.5.1
|
||||
notebook==6.2.0
|
||||
numpy==1.19.5
|
||||
packaging==20.9
|
||||
pandocfilters==1.4.3
|
||||
parso==0.8.1
|
||||
pexpect==4.8.0
|
||||
pickleshare==0.7.5
|
||||
Pillow==8.0.0
|
||||
prometheus-client==0.8.0
|
||||
prompt-toolkit==3.0.8
|
||||
ptyprocess==0.6.0
|
||||
Pillow==8.1.0
|
||||
prometheus-client==0.9.0
|
||||
prompt-toolkit==3.0.16
|
||||
ptyprocess==0.7.0
|
||||
pycparser==2.20
|
||||
Pygments==2.7.1
|
||||
Pygments==2.8.0
|
||||
pyparsing==2.4.7
|
||||
pyrsistent==0.17.3
|
||||
python-dateutil==2.8.1
|
||||
pyzmq==19.0.2
|
||||
pyzmq==22.0.3
|
||||
qrcode==6.1
|
||||
Send2Trash==1.5.0
|
||||
six==1.15.0
|
||||
terminado==0.9.1
|
||||
sniffio==1.2.0
|
||||
terminado==0.9.2
|
||||
testpath==0.4.4
|
||||
tornado==6.0.4
|
||||
tornado==6.1
|
||||
traitlets==4.3.3
|
||||
voila==0.2.4
|
||||
typing-extensions==3.7.4.3
|
||||
voila==0.2.7
|
||||
wcwidth==0.2.5
|
||||
webencodings==0.5.1
|
||||
widgetsnbextension==3.5.1
|
||||
zipp==3.3.1
|
||||
zipp==3.4.0
|
||||
|
|
|
|||
|
|
@ -16,3 +16,6 @@ ignore_missing_imports = True
|
|||
|
||||
[mypy-mistune.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-qrcode.*]
|
||||
ignore_missing_imports = True
|
||||
|
|
|
|||
1
setup.py
1
setup.py
|
|
@ -24,6 +24,7 @@ REQUIREMENTS: dict = {
|
|||
'matplotlib',
|
||||
'mistune',
|
||||
'numpy',
|
||||
'qrcode[pil]',
|
||||
'tornado',
|
||||
'voila >=0.2.4',
|
||||
],
|
||||
|
|
|
|||
Loading…
Reference in a new issue