From ad2fc938405fce6a93146fc58844875cc245a633 Mon Sep 17 00:00:00 2001 From: Graham Lee Date: Wed, 27 Jul 2022 15:04:16 +0100 Subject: [PATCH] Create AgeRange class for age ranges #2714 --- .../data_service/model/age_range.py | 41 +++++++++++ .../tests/test_age_range.py | 68 +++++++++++++++++++ .../tests/test_geojson_model.py | 11 +-- .../reusable-data-service/tests/util.py | 10 +++ 4 files changed, 120 insertions(+), 10 deletions(-) create mode 100644 data-serving/reusable-data-service/data_service/model/age_range.py create mode 100644 data-serving/reusable-data-service/tests/test_age_range.py create mode 100644 data-serving/reusable-data-service/tests/util.py diff --git a/data-serving/reusable-data-service/data_service/model/age_range.py b/data-serving/reusable-data-service/data_service/model/age_range.py new file mode 100644 index 000000000..5fdd3ed66 --- /dev/null +++ b/data-serving/reusable-data-service/data_service/model/age_range.py @@ -0,0 +1,41 @@ +import dataclasses + +from data_service.model.document import Document +from data_service.util.errors import ValidationError + +@dataclasses.dataclass +class AgeRange(Document): + """I represent a numerical range within which a person's age lies (inclusive of both limits). + To avoid reidentifying people who have been anonymised by this + application, I will only tell you their age to within five years (unless they are infants).""" + lower: int = None + upper: int = None + + def __post_init__(self): + """Massage the supplied lower and upper bounds to fit our requirements. That doesn't + preclude somebody changing the values after initialisation so please do remember to + validate() me.""" + if self.lower is not None and self.lower != 0: + self.lower = (self.lower // 5) * 5 + 1 + if self.upper is not None and self.upper != 1 and self.upper % 5 != 0: + self.upper = ((self.upper // 5) + 1) * 5 + + def validate(self): + """I must represent the range [0,1], or a range greater than five years, and must + have a positive lower bound and an upper bound below 121.""" + if self.lower is None: + raise ValidationError("Age Range must have a lower bound") + if self.upper is None: + raise ValidationError("Age Range must have an upper bound") + if self.lower < 0: + raise ValidationError(f"Lower bound {self.lower} is below the minimum permissible 0") + if self.upper < 1: + raise ValidationError(f"Upper bound {self.upper} is below the minimum permissible 1") + if self.upper > 120: + raise ValidationError(f"Upper bound {self.upper} is above the maximum permissible 120") + # deal with the special case first + if self.lower == 0 and self.upper == 1: + return + if self.upper - self.lower < 4: + # remember range is inclusive of bounds so e.g. 1-5 is five years + raise ValidationError(f"Range [{self.lower}, {self.upper}] is too small") diff --git a/data-serving/reusable-data-service/tests/test_age_range.py b/data-serving/reusable-data-service/tests/test_age_range.py new file mode 100644 index 000000000..ee51772af --- /dev/null +++ b/data-serving/reusable-data-service/tests/test_age_range.py @@ -0,0 +1,68 @@ +import pytest + +from data_service.model.age_range import AgeRange +from data_service.util.errors import ValidationError + +from tests.util import does_not_raise + + +def test_age_range_massages_input_to_match_buckets(): + ages = AgeRange(2,6) + assert ages.lower == 1 + assert ages.upper == 10 + + +def test_age_range_leaves_input_if_it_is_already_on_bucket_boundary(): + ages = AgeRange(6, 10) + assert ages.lower == 6 + assert ages.upper == 10 + + +def test_age_range_invalid_if_boundaries_are_None(): + ages = AgeRange(None, None) + with pytest.raises(ValidationError): + ages.validate() + ages = AgeRange(None, 12) + with pytest.raises(ValidationError): + ages.validate() + ages = AgeRange(5, None) + with pytest.raises(ValidationError): + ages.validate() + + +def test_age_range_invalid_if_lower_bound_negative(): + ages = AgeRange(-12, 4) + with pytest.raises(ValidationError): + ages.validate() + + +def test_age_range_invalid_if_upper_bound_negative(): + ages = AgeRange(5, -27) + with pytest.raises(ValidationError): + ages.validate() + + +def test_age_range_invalid_if_upper_bound_methuselan(): + ages = AgeRange(15, 150) + with pytest.raises(ValidationError): + ages.validate() + + +def test_age_range_invalid_if_gap_too_small(): + ages = AgeRange(None, None) + ages.lower = 10 + ages.upper = 11 + with pytest.raises(ValidationError): + ages.validate() + + +def test_age_range_ok_for_infants(): + ages = AgeRange(0, 1) + with does_not_raise(ValidationError): + ages.validate() + + +def test_age_range_ok_for_large_positive_range(): + ages = AgeRange(30, 45) + with does_not_raise(ValidationError): + ages.validate() diff --git a/data-serving/reusable-data-service/tests/test_geojson_model.py b/data-serving/reusable-data-service/tests/test_geojson_model.py index ac3ab4051..6ae19f204 100644 --- a/data-serving/reusable-data-service/tests/test_geojson_model.py +++ b/data-serving/reusable-data-service/tests/test_geojson_model.py @@ -1,18 +1,9 @@ import pytest -from contextlib import contextmanager - from data_service.model.geojson import Point, Feature from data_service.util.errors import ValidationError - -@contextmanager -def does_not_raise(exception): - try: - yield - except exception: - raise pytest.fail(f"Exception {exception} expected not to be raised") - +from tests.util import does_not_raise def test_point_needs_two_coordinates(): p = Point() diff --git a/data-serving/reusable-data-service/tests/util.py b/data-serving/reusable-data-service/tests/util.py new file mode 100644 index 000000000..f0825eb68 --- /dev/null +++ b/data-serving/reusable-data-service/tests/util.py @@ -0,0 +1,10 @@ +import pytest + +from contextlib import contextmanager + +@contextmanager +def does_not_raise(exception): + try: + yield + except exception: + raise pytest.fail(f"Exception {exception} expected not to be raised")