Merge branch 'feature/shorter_url' into 'master'
Clean up the FormData defaults and casting rules Closes #143 See merge request cara/cara!155
This commit is contained in:
commit
2a43eea709
8 changed files with 238 additions and 54 deletions
|
|
@ -24,7 +24,7 @@ from .user import AuthenticatedUser, AnonymousUser
|
||||||
# calculator version. If the calculator needs to make breaking changes (e.g. change
|
# 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
|
# form attributes) then it can also increase its MAJOR version without needing to
|
||||||
# increase the overall CARA version (found at ``cara.__version__``).
|
# increase the overall CARA version (found at ``cara.__version__``).
|
||||||
__version__ = "1.5.0"
|
__version__ = "1.5.1"
|
||||||
|
|
||||||
|
|
||||||
class BaseRequestHandler(RequestHandler):
|
class BaseRequestHandler(RequestHandler):
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import dataclasses
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import html
|
import html
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -14,6 +15,13 @@ LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
minutes_since_midnight = typing.NewType('minutes_since_midnight', int)
|
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
|
@dataclass
|
||||||
class FormData:
|
class FormData:
|
||||||
activity_type: str
|
activity_type: str
|
||||||
|
|
@ -60,51 +68,100 @@ class FormData:
|
||||||
windows_number: int
|
windows_number: int
|
||||||
window_opening_regime: str
|
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
|
@classmethod
|
||||||
def from_dict(cls, form_data: typing.Dict) -> "FormData":
|
def from_dict(cls, form_data: typing.Dict) -> "FormData":
|
||||||
# Take a copy of the form data so that we can mutate it.
|
# Take a copy of the form data so that we can mutate it.
|
||||||
form_data = form_data.copy()
|
form_data = form_data.copy()
|
||||||
|
form_data.pop('_xsrf', None)
|
||||||
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'
|
|
||||||
|
|
||||||
# Don't let arbitrary unescaped HTML through the net.
|
# Don't let arbitrary unescaped HTML through the net.
|
||||||
for key, value in form_data.items():
|
for key, value in form_data.items():
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
form_data[key] = html.escape(value)
|
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():
|
for key, value in form_data.items():
|
||||||
if value == "":
|
if key in _CAST_RULES_FORM_ARG_TO_NATIVE:
|
||||||
form_data[key] = "0"
|
form_data[key] = _CAST_RULES_FORM_ARG_TO_NATIVE[key](value)
|
||||||
|
|
||||||
for attr_name in BOOLEAN_ATTRIBUTES:
|
if key not in cls._DEFAULTS:
|
||||||
form_data[attr_name] = form_data[attr_name] == '1'
|
raise ValueError(f'Invalid argument "{html.escape(key)}" given')
|
||||||
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])
|
|
||||||
|
|
||||||
form_data.pop('_xsrf', None)
|
|
||||||
instance = cls(**form_data)
|
instance = cls(**form_data)
|
||||||
instance.validate()
|
instance.validate()
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def to_dict(self, form: "FormData") -> dict:
|
def to_dict(cls, form: "FormData", strip_defaults: bool = False) -> dict:
|
||||||
form_dict = form.__dict__.copy()
|
form_dict = {
|
||||||
for attr_name in TIME_ATTRIBUTES:
|
field.name: getattr(form, field.name)
|
||||||
form_dict[attr_name] = time_minutes_to_string(form_dict[attr_name])
|
for field in dataclasses.fields(form)
|
||||||
for attr_name in BOOLEAN_ATTRIBUTES:
|
}
|
||||||
form_dict[attr_name] = form_dict[attr_name] & 1
|
|
||||||
|
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
|
return form_dict
|
||||||
|
|
||||||
def validate(self):
|
def validate(self):
|
||||||
|
|
@ -140,12 +197,22 @@ class FormData:
|
||||||
if getattr(self, attr_name) not in valid_set:
|
if getattr(self, attr_name) not in valid_set:
|
||||||
raise ValueError(f"{getattr(self, attr_name)} is not a valid value for {attr_name}")
|
raise ValueError(f"{getattr(self, attr_name)} is not a valid value for {attr_name}")
|
||||||
|
|
||||||
if (
|
if self.ventilation_type == 'natural_ventilation':
|
||||||
self.ventilation_type == 'natural'
|
if self.window_type == 'not-applicable':
|
||||||
and self.window_type == 'not-applicable'
|
raise ValueError(
|
||||||
):
|
"window_type cannot be 'not-applicable' if "
|
||||||
raise ValueError("window_type cannot be 'not-applicable' if "
|
"ventilation_type is 'natural_ventilation'"
|
||||||
"ventilation_type is 'natural'")
|
)
|
||||||
|
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:
|
def build_model(self) -> models.ExposureModel:
|
||||||
return model_from_form(self)
|
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_OPENING_REGIMES = {'windows_open_permanently', 'windows_open_periodically', 'not-applicable'}
|
||||||
WINDOWS_TYPES = {'window_sliding', 'window_hinged', '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:
|
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:]))
|
return minutes_since_midnight(60 * int(time[:2]) + int(time[3:]))
|
||||||
|
|
||||||
|
|
||||||
def time_minutes_to_string(time: int) -> str:
|
def time_minutes_to_string(time: int) -> str:
|
||||||
"""
|
"""
|
||||||
Converts time from an integer number of minutes after 00:00 to string-format
|
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: 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)
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import numpy as np
|
||||||
|
|
||||||
from cara import models
|
from cara import models
|
||||||
from .model_generator import FormData
|
from .model_generator import FormData
|
||||||
|
from ... import dataclass_utils
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass(frozen=True)
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
|
@ -46,7 +47,8 @@ def calculate_report_data(model: models.ExposureModel):
|
||||||
|
|
||||||
repeated_events = []
|
repeated_events = []
|
||||||
for n in [1, 2, 3, 4, 5]:
|
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(
|
repeated_events.append(
|
||||||
RepeatEvents(
|
RepeatEvents(
|
||||||
repeats=n,
|
repeats=n,
|
||||||
|
|
@ -69,7 +71,7 @@ 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, strip_defaults=True)
|
||||||
|
|
||||||
# Generate the calculator URL arguments that would be needed to re-create this
|
# Generate the calculator URL arguments that would be needed to re-create this
|
||||||
# form.
|
# form.
|
||||||
|
|
@ -197,12 +199,12 @@ def manufacture_alternative_scenarios(form: FormData) -> typing.Dict[str, models
|
||||||
|
|
||||||
# The remaining scenarios are based on Type I masks (possibly not worn)
|
# The remaining scenarios are based on Type I masks (possibly not worn)
|
||||||
# and no HEPA filtration.
|
# 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:
|
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')
|
with_mask = dataclass_utils.replace(form, mask_wearing_option='mask_on')
|
||||||
without_mask = dataclasses.replace(form, mask_wearing_option='mask_off')
|
without_mask = dataclass_utils.replace(form, mask_wearing_option='mask_off')
|
||||||
|
|
||||||
if form.ventilation_type == 'mechanical_ventilation':
|
if form.ventilation_type == 'mechanical_ventilation':
|
||||||
scenarios['Mechanical ventilation with Type I masks'] = with_mask.build_model()
|
scenarios['Mechanical ventilation with Type I masks'] = with_mask.build_model()
|
||||||
|
|
@ -213,8 +215,8 @@ def manufacture_alternative_scenarios(form: FormData) -> typing.Dict[str, models
|
||||||
scenarios['Windows open without masks'] = without_mask.build_model()
|
scenarios['Windows open without masks'] = without_mask.build_model()
|
||||||
|
|
||||||
# No matter the ventilation scheme, we include scenarios which don't have any ventilation.
|
# 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')
|
with_mask_no_vent = dataclass_utils.replace(with_mask, ventilation_type='no_ventilation')
|
||||||
without_mask_or_vent = dataclasses.replace(without_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['No ventilation with Type I masks'] = with_mask_no_vent.build_model()
|
||||||
scenarios['Neither ventilation nor masks'] = without_mask_or_vent.build_model()
|
scenarios['Neither ventilation nor masks'] = without_mask_or_vent.build_model()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -474,6 +474,8 @@ $(document).ready(function () {
|
||||||
|
|
||||||
//Pre-select checked radios
|
//Pre-select checked radios
|
||||||
if (elemObj.type === 'radio') {
|
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') {
|
if (value !== 'not-applicable') {
|
||||||
$('[name="'+name+'"][value="'+value+'"]').prop('checked',true);
|
$('[name="'+name+'"][value="'+value+'"]').prop('checked',true);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -160,11 +160,11 @@ v{{ calculator_version }} <span style="float:right; font-weight:bold">Please sen
|
||||||
<option value="gym">Gym</option>
|
<option value="gym">Gym</option>
|
||||||
</select><br>
|
</select><br>
|
||||||
Exposed person(s) presence: <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>
|
<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>
|
||||||
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>
|
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>
|
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>
|
<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>
|
||||||
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>
|
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%">
|
<hr width="80%">
|
||||||
|
|
||||||
Which month is the event?
|
Which month is the event?
|
||||||
|
|
|
||||||
|
|
@ -24,3 +24,22 @@ def nested_replace(obj, new_values: typing.Dict[str, typing.Any]):
|
||||||
# We have a plain old name. So set it.
|
# We have a plain old name. So set it.
|
||||||
new_inst = dataclasses.replace(new_inst, **{name: value})
|
new_inst = dataclasses.replace(new_inst, **{name: value})
|
||||||
return new_inst
|
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
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import dataclasses
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from cara.apps.calculator import model_generator
|
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)
|
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():
|
def test_blend_expiration():
|
||||||
blend = {'Breathing': 2, 'Talking': 1}
|
blend = {'Breathing': 2, 'Talking': 1}
|
||||||
r = model_generator.build_expiration(blend)
|
r = model_generator.build_expiration(blend)
|
||||||
|
|
@ -381,3 +389,60 @@ def test_key_validation(baseline_form_data):
|
||||||
baseline_form_data['activity_type'] = 'invalid key'
|
baseline_form_data['activity_type'] = 'invalid key'
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
model_generator.FormData.from_dict(baseline_form_data)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ class TestCernApp(tornado.testing.AsyncHTTPTestCase):
|
||||||
async def test_qrcode_urls(http_server_client, baseline_form):
|
async def test_qrcode_urls(http_server_client, baseline_form):
|
||||||
prefix = 'proto://hostname/prefix'
|
prefix = 'proto://hostname/prefix'
|
||||||
qr_data = generate_qr_code(prefix, baseline_form)
|
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)
|
assert qr_data['link'].startswith(expected)
|
||||||
|
|
||||||
# We should get a 200 for the link.
|
# We should get a 200 for the link.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue