Clean up the FormData defaults and casting rules. Using this information we now shorten the FormData dictionary. The baseline form URL is now 490 characters compared to 1089 previously. Closes #143.

This commit is contained in:
Philip James Elson 2021-04-21 14:07:50 +00:00
parent 7152be47e6
commit 48b9c91f43
8 changed files with 238 additions and 54 deletions

View file

@ -23,7 +23,7 @@ from .user import AuthenticatedUser, AnonymousUser
# 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
# increase the overall CARA version (found at ``cara.__version__``).
__version__ = "1.5.0"
__version__ = "1.5.1"
class BaseRequestHandler(RequestHandler):

View file

@ -1,3 +1,4 @@
import dataclasses
from dataclasses import dataclass
import html
import logging
@ -14,6 +15,13 @@ LOG = logging.getLogger(__name__)
minutes_since_midnight = typing.NewType('minutes_since_midnight', int)
# Used to declare when an attribute of a class must have a value provided, and
# there should be no default value used.
_NO_DEFAULT = object()
@dataclass
class FormData:
activity_type: str
@ -60,51 +68,100 @@ class FormData:
windows_number: int
window_opening_regime: str
#: The default values for undefined fields. Note that the defaults here
#: and the defaults in the html form must not be contradictory.
_DEFAULTS: typing.ClassVar[typing.Dict[str, typing.Any]] = {
'activity_type': 'office',
'air_changes': 0.,
'air_supply': 0.,
'calculator_version': _NO_DEFAULT,
'ceiling_height': 0.,
'exposed_coffee_break_option': 'coffee_break_0',
'exposed_coffee_duration': 5,
'exposed_finish': '17:30',
'exposed_lunch_finish': '13:30',
'exposed_lunch_option': True,
'exposed_lunch_start': '12:30',
'exposed_start': '08:30',
'event_month': 'January',
'floor_area': 0.,
'hepa_amount': 0.,
'hepa_option': False,
'infected_coffee_break_option': 'coffee_break_0',
'infected_coffee_duration': 5,
'infected_dont_have_breaks_with_exposed': False,
'infected_finish': '17:30',
'infected_lunch_finish': '13:30',
'infected_lunch_option': True,
'infected_lunch_start': '12:30',
'infected_people': _NO_DEFAULT,
'infected_start': '08:30',
'mask_type': 'Type I',
'mask_wearing_option': 'mask_off',
'mechanical_ventilation_type': 'not-applicable',
'opening_distance': 0.,
'room_number': _NO_DEFAULT,
'room_volume': 0.,
'simulation_name': _NO_DEFAULT,
'total_people': _NO_DEFAULT,
'ventilation_type': 'no_ventilation',
'virus_type': 'SARS_CoV_2',
'volume_type': _NO_DEFAULT,
'window_type': 'window_sliding',
'window_height': 0.,
'window_width': 0.,
'windows_duration': 0.,
'windows_frequency': 0.,
'windows_number': 0,
'window_opening_regime': 'windows_open_permanently',
}
@classmethod
def from_dict(cls, form_data: typing.Dict) -> "FormData":
# Take a copy of the form data so that we can mutate it.
form_data = form_data.copy()
valid_na_values = ['window_opening_regime', 'window_type', 'mechanical_ventilation_type', 'infected_dont_have_breaks_with_exposed']
for name in valid_na_values:
if not form_data.get(name, ''):
form_data[name] = 'not-applicable'
for name in ['exposed_lunch_start', 'exposed_lunch_finish', 'infected_lunch_start', 'infected_lunch_finish']:
if not form_data.get(name, ''):
form_data[name] = '00:00'
form_data.pop('_xsrf', None)
# Don't let arbitrary unescaped HTML through the net.
for key, value in form_data.items():
if isinstance(value, str):
form_data[key] = html.escape(value)
# TODO: This fixup is a problem with the form.html.
for key, default_value in cls._DEFAULTS.items():
if form_data.get(key, '') == '':
if default_value is _NO_DEFAULT:
raise ValueError(f"{key} must be specified")
form_data[key] = default_value
for key, value in form_data.items():
if value == "":
form_data[key] = "0"
if key in _CAST_RULES_FORM_ARG_TO_NATIVE:
form_data[key] = _CAST_RULES_FORM_ARG_TO_NATIVE[key](value)
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])
if key not in cls._DEFAULTS:
raise ValueError(f'Invalid argument "{html.escape(key)}" given')
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
def to_dict(cls, form: "FormData", strip_defaults: bool = False) -> dict:
form_dict = {
field.name: getattr(form, field.name)
for field in dataclasses.fields(form)
}
for attr, value in form_dict.items():
if attr in _CAST_RULES_NATIVE_TO_FORM_ARG:
form_dict[attr] = _CAST_RULES_NATIVE_TO_FORM_ARG[attr](value)
if strip_defaults:
del form_dict['calculator_version']
for attr, value in list(form_dict.items()):
default = cls._DEFAULTS.get(attr, _NO_DEFAULT)
if default is not _NO_DEFAULT and value in [default, 'not-applicable']:
form_dict.pop(attr)
return form_dict
def validate(self):
@ -140,12 +197,22 @@ class FormData:
if getattr(self, attr_name) not in valid_set:
raise ValueError(f"{getattr(self, attr_name)} is not a valid value for {attr_name}")
if (
self.ventilation_type == 'natural'
and self.window_type == 'not-applicable'
):
raise ValueError("window_type cannot be 'not-applicable' if "
"ventilation_type is 'natural'")
if self.ventilation_type == 'natural_ventilation':
if self.window_type == 'not-applicable':
raise ValueError(
"window_type cannot be 'not-applicable' if "
"ventilation_type is 'natural_ventilation'"
)
if self.window_opening_regime == 'not-applicable':
raise ValueError(
"window_opening_regime cannot be 'not-applicable' if "
"ventilation_type is 'natural_ventilation'"
)
if (self.ventilation_type == 'mechanical_ventilation'
and self.mechanical_ventilation_type == 'not-applicable'):
raise ValueError("mechanical_ventilation_type cannot be 'not-applicable' if "
"ventilation_type is 'mechanical_ventilation'")
def build_model(self) -> models.ExposureModel:
return model_from_form(self)
@ -582,14 +649,8 @@ VOLUME_TYPES = {'room_volume_explicit', 'room_volume_from_dimensions'}
WINDOWS_OPENING_REGIMES = {'windows_open_permanently', 'windows_open_periodically', 'not-applicable'}
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}
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:
"""
@ -599,6 +660,7 @@ def time_string_to_minutes(time: str) -> minutes_since_midnight:
"""
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
@ -606,3 +668,37 @@ def time_minutes_to_string(time: int) -> str:
: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)
def _safe_int_cast(value) -> int:
if isinstance(value, int):
return value
elif isinstance(value, float) and int(value) == value:
return int(value)
elif isinstance(value, str) and value.isdecimal():
return int(value)
else:
raise TypeError(f"Unable to safely cast {value} ({type(value)} type) to int")
#: Mapping of field name to a callable which can convert values from form
#: input (URL encoded arguments / string) into the correct type.
_CAST_RULES_FORM_ARG_TO_NATIVE: typing.Dict[str, typing.Callable] = {}
#: Mapping of field name to callable which can convert native type to values
#: that can be encoded to URL arguments.
_CAST_RULES_NATIVE_TO_FORM_ARG: typing.Dict[str, typing.Callable] = {}
for _field in dataclasses.fields(FormData):
if _field.type is minutes_since_midnight:
_CAST_RULES_FORM_ARG_TO_NATIVE[_field.name] = time_string_to_minutes
_CAST_RULES_NATIVE_TO_FORM_ARG[_field.name] = time_minutes_to_string
elif _field.type is int:
_CAST_RULES_FORM_ARG_TO_NATIVE[_field.name] = _safe_int_cast
elif _field.type is float:
_CAST_RULES_FORM_ARG_TO_NATIVE[_field.name] = float
elif _field.type is bool:
_CAST_RULES_FORM_ARG_TO_NATIVE[_field.name] = lambda v: v == '1'
_CAST_RULES_NATIVE_TO_FORM_ARG[_field.name] = int

View file

@ -16,6 +16,7 @@ import numpy as np
from cara import models
from .model_generator import FormData
from ... import dataclass_utils
@dataclasses.dataclass(frozen=True)
@ -47,7 +48,8 @@ def calculate_report_data(model: models.ExposureModel):
repeated_events = []
for n in [1, 2, 3, 4, 5]:
repeat_model = dataclasses.replace(model, repeats=n)
repeat_model = dataclass_utils.replace(model, repeats=n)
repeated_events.append(
RepeatEvents(
repeats=n,
@ -70,7 +72,7 @@ def calculate_report_data(model: models.ExposureModel):
def generate_qr_code(prefix, form: FormData):
form_dict = FormData.to_dict(form)
form_dict = FormData.to_dict(form, strip_defaults=True)
# Generate the calculator URL arguments that would be needed to re-create this
# form.
@ -198,12 +200,12 @@ def manufacture_alternative_scenarios(form: FormData) -> typing.Dict[str, models
# The remaining scenarios are based on Type I masks (possibly not worn)
# and no HEPA filtration.
form = dataclasses.replace(form, mask_type='Type I')
form = dataclass_utils.replace(form, mask_type='Type I')
if form.hepa_option:
form = dataclasses.replace(form, hepa_option=False)
form = dataclass_utils.replace(form, hepa_option=False)
with_mask = dataclasses.replace(form, mask_wearing_option='mask_on')
without_mask = dataclasses.replace(form, mask_wearing_option='mask_off')
with_mask = dataclass_utils.replace(form, mask_wearing_option='mask_on')
without_mask = dataclass_utils.replace(form, mask_wearing_option='mask_off')
if form.ventilation_type == 'mechanical_ventilation':
scenarios['Mechanical ventilation with Type I masks'] = with_mask.build_model()
@ -214,8 +216,8 @@ def manufacture_alternative_scenarios(form: FormData) -> typing.Dict[str, models
scenarios['Windows open without masks'] = without_mask.build_model()
# No matter the ventilation scheme, we include scenarios which don't have any ventilation.
with_mask_no_vent = dataclasses.replace(with_mask, ventilation_type='no_ventilation')
without_mask_or_vent = dataclasses.replace(without_mask, ventilation_type='no_ventilation')
with_mask_no_vent = dataclass_utils.replace(with_mask, ventilation_type='no_ventilation')
without_mask_or_vent = dataclass_utils.replace(without_mask, ventilation_type='no_ventilation')
scenarios['No ventilation with Type I masks'] = with_mask_no_vent.build_model()
scenarios['Neither ventilation nor masks'] = without_mask_or_vent.build_model()

View file

@ -474,6 +474,8 @@ $(document).ready(function () {
//Pre-select checked radios
if (elemObj.type === 'radio') {
// Calculator <= 1.5.0 used to send not-applicable in the URL for radios that
// weren't set. Now those are not sent at all, but we keep the behaviour for compatibility.
if (value !== 'not-applicable') {
$('[name="'+name+'"][value="'+value+'"]').prop('checked',true);
}

View file

@ -160,11 +160,11 @@ v{{ calculator_version }} <span style="float:right; font-weight:bold">Please sen
<option value="gym">Gym</option>
</select><br>
Exposed person(s) presence: <br>
<span class="tabbed">Start: </span><input type="time" id="exposed_start" class="start_time" data-time-group="exposed" data-lunch-break="exposed_lunch" name="exposed_start" value="09:00" required> &nbsp;&nbsp;
Finish: <input type="time" id="exposed_finish" class="finish_time" data-time-group="exposed" data-lunch-break="exposed_lunch" name="exposed_finish" value="18:00" required><br>
<span class="tabbed">Start: </span><input type="time" id="exposed_start" class="start_time" data-time-group="exposed" data-lunch-break="exposed_lunch" name="exposed_start" value="08:30" required> &nbsp;&nbsp;
Finish: <input type="time" id="exposed_finish" class="finish_time" data-time-group="exposed" data-lunch-break="exposed_lunch" name="exposed_finish" value="17:30" required><br>
Infected person(s) presence: <br>
<span class="tabbed">Start: </span><input type="time" id="infected_start" class="start_time" data-time-group="infected" data-lunch-break="infected_lunch" name="infected_start" value="09:00" required> &nbsp;&nbsp;
Finish: <input type="time" id="infected_finish" class="finish_time" data-time-group="infected" data-lunch-break="infected_lunch" name="infected_finish" value="18:00" required><br>
<span class="tabbed">Start: </span><input type="time" id="infected_start" class="start_time" data-time-group="infected" data-lunch-break="infected_lunch" name="infected_start" value="08:30" required> &nbsp;&nbsp;
Finish: <input type="time" id="infected_finish" class="finish_time" data-time-group="infected" data-lunch-break="infected_lunch" name="infected_finish" value="17:30" required><br>
<hr width="80%">
Which month is the event?

View file

@ -24,3 +24,22 @@ def nested_replace(obj, new_values: typing.Dict[str, typing.Any]):
# We have a plain old name. So set it.
new_inst = dataclasses.replace(new_inst, **{name: value})
return new_inst
def replace(obj, **changes):
"""
A version of dataclasses.replace that handles ClassVar declarations.
See https://bugs.python.org/issue33796.
"""
orig = obj.__dataclass_fields__
object.__setattr__(
obj, '__dataclass_fields__',
{name: field for name, field in orig.items()
if field._field_type is not dataclasses._FIELD_CLASSVAR}
)
new = dataclasses.replace(obj, **changes)
object.__setattr__(obj, '__dataclass_fields__', orig)
return new

View file

@ -1,3 +1,5 @@
import dataclasses
import pytest
from cara.apps.calculator import model_generator
@ -12,6 +14,12 @@ def test_model_from_dict(baseline_form_data):
assert isinstance(form.build_model(), models.ExposureModel)
def test_model_from_dict_invalid(baseline_form_data):
baseline_form_data['invalid_item'] = 'foobar'
with pytest.raises(ValueError, match='Invalid argument "invalid_item" given'):
model_generator.FormData.from_dict(baseline_form_data)
def test_blend_expiration():
blend = {'Breathing': 2, 'Talking': 1}
r = model_generator.build_expiration(blend)
@ -381,3 +389,60 @@ def test_key_validation(baseline_form_data):
baseline_form_data['activity_type'] = 'invalid key'
with pytest.raises(ValueError):
model_generator.FormData.from_dict(baseline_form_data)
def test_key_validation_natural_ventilation_window_type_na(baseline_form_data):
baseline_form_data['ventilation_type'] = 'natural_ventilation'
baseline_form_data['window_type'] = 'not-applicable'
with pytest.raises(ValueError, match='window_type cannot be \'not-applicable\''):
model_generator.FormData.from_dict(baseline_form_data)
def test_key_validation_natural_ventilation_window_opening_regime_na(baseline_form_data):
baseline_form_data['ventilation_type'] = 'natural_ventilation'
baseline_form_data['window_opening_regime'] = 'not-applicable'
with pytest.raises(ValueError, match='window_opening_regime cannot be \'not-applicable\''):
model_generator.FormData.from_dict(baseline_form_data)
def test_key_validation_mech_ventilation_type_na(baseline_form_data):
baseline_form_data['ventilation_type'] = 'mechanical_ventilation'
baseline_form_data['mechanical_ventilation_type'] = 'not-applicable'
with pytest.raises(ValueError, match='mechanical_ventilation_type cannot be \'not-applicable\''):
model_generator.FormData.from_dict(baseline_form_data)
def test_default_types():
# Validate that FormData._DEFAULTS are complete and of the correct type.
# Validate that we have the right types and matching attributes to the DEFAULTS.
fields = {field.name: field for field in dataclasses.fields(model_generator.FormData)}
for field, value in model_generator.FormData._DEFAULTS.items():
if field not in fields:
raise ValueError(f"Unmatched default {field}")
field_type = fields[field].type
if not isinstance(field_type, type):
# Handle typing.NewType definitions.
field_type = field_type.__supertype__
if value is model_generator._NO_DEFAULT:
continue
if field in model_generator._CAST_RULES_FORM_ARG_TO_NATIVE:
value = model_generator._CAST_RULES_FORM_ARG_TO_NATIVE[field](value)
if not isinstance(value, field_type):
raise TypeError(f'{field} has type {field_type}, got {type(value)}')
for field in fields.values():
assert field.name in model_generator.FormData._DEFAULTS, f"No default set for field name {field.name}"
def test_form_to_dict(baseline_form):
full = baseline_form.to_dict(baseline_form)
stripped = baseline_form.to_dict(baseline_form, strip_defaults=True)
assert 1 < len(stripped) < len(full)
assert 'exposed_coffee_break_option' in stripped
# If we set the value to the default one, it should no longer turn up in the dictionary.
baseline_form.exposed_coffee_break_option = model_generator.FormData._DEFAULTS['exposed_coffee_break_option']
assert 'exposed_coffee_break_option' not in baseline_form.to_dict(baseline_form, strip_defaults=True)

View file

@ -26,7 +26,7 @@ async def test_calculator(http_server_client):
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'
expected = f'{prefix}/calculator?exposed_coffee_break_option={baseline_form.exposed_coffee_break_option}&'
assert qr_data['link'].startswith(expected)
# We should get a 200 for the link.