Skip to content

Commit 202f0a3

Browse files
authored
Future Date Validation (#841)
* Datetime must not be in the future
1 parent 48fd36d commit 202f0a3

File tree

5 files changed

+55
-13
lines changed

5 files changed

+55
-13
lines changed

backend/src/models/fhir_immunization_pre_validators.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -729,7 +729,7 @@ def pre_validate_expiration_date(self, values: dict) -> dict:
729729
"""
730730
try:
731731
field_value = values["expirationDate"]
732-
PreValidation.for_date(field_value, "expirationDate")
732+
PreValidation.for_date(field_value, "expirationDate", future_date_allowed=True)
733733
except KeyError:
734734
pass
735735

backend/src/models/utils/pre_validator_utils.py

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from datetime import datetime, timedelta
33
from decimal import Decimal
44
from typing import Union
5+
from datetime import datetime, date
56

67
from .generic_utils import nhs_number_mod11_check, is_valid_simple_snomed
78

@@ -82,7 +83,7 @@ def for_list(
8283
raise ValueError(f"{field_location} must be an array of non-empty objects")
8384

8485
@staticmethod
85-
def for_date(field_value: str, field_location: str):
86+
def for_date(field_value: str, field_location: str, future_date_allowed: bool = False):
8687
"""
8788
Apply pre-validation to a date field to ensure that it is a string (JSON dates must be
8889
written as strings) containing a valid date in the format "YYYY-MM-DD"
@@ -91,12 +92,16 @@ def for_date(field_value: str, field_location: str):
9192
raise TypeError(f"{field_location} must be a string")
9293

9394
try:
94-
datetime.strptime(field_value, "%Y-%m-%d").date()
95+
parsed_date = datetime.strptime(field_value, "%Y-%m-%d").date()
9596
except ValueError as value_error:
9697
raise ValueError(
9798
f'{field_location} must be a valid date string in the format "YYYY-MM-DD"'
9899
) from value_error
99100

101+
# Enforce future date rule using central checker after successful parse
102+
if not future_date_allowed and PreValidation.check_if_future_date(parsed_date):
103+
raise ValueError(f"{field_location} must not be in the future")
104+
100105
@staticmethod
101106
def for_date_time(field_value: str, field_location: str, strict_timezone: bool = True):
102107
"""
@@ -116,11 +121,13 @@ def for_date_time(field_value: str, field_location: str, strict_timezone: bool =
116121
"- 'YYYY-MM-DD' — Full date only"
117122
"- 'YYYY-MM-DDThh:mm:ss%z' — Full date and time with timezone (e.g. +00:00 or +01:00)"
118123
"- 'YYYY-MM-DDThh:mm:ss.f%z' — Full date and time with milliseconds and timezone"
124+
"- Date must not be in the future."
119125
)
120-
121126
if strict_timezone:
122-
error_message += "Only '+00:00' and '+01:00' are accepted as valid timezone offsets.\n"
123-
error_message += f"Note that partial dates are not allowed for {field_location} in this service."
127+
error_message += (
128+
"Only '+00:00' and '+01:00' are accepted as valid timezone offsets.\n"
129+
f"Note that partial dates are not allowed for {field_location} in this service.\n"
130+
)
124131

125132
allowed_suffixes = {"+00:00", "+01:00", "+0000", "+0100",}
126133

@@ -133,10 +140,13 @@ def for_date_time(field_value: str, field_location: str, strict_timezone: bool =
133140
for fmt in formats:
134141
try:
135142
fhir_date = datetime.strptime(field_value, fmt)
136-
143+
# Enforce future-date rule using central checker after successful parse
144+
if PreValidation.check_if_future_date(fhir_date):
145+
raise ValueError(f"{field_location} must not be in the future")
146+
# After successful parse, enforce timezone and future-date rules
137147
if strict_timezone and fhir_date.tzinfo is not None:
138-
if not any(field_value.endswith(suffix) for suffix in allowed_suffixes):
139-
raise ValueError(error_message)
148+
if not any(field_value.endswith(suffix) for suffix in allowed_suffixes):
149+
raise ValueError(error_message)
140150
return fhir_date.isoformat()
141151
except ValueError:
142152
continue
@@ -234,3 +244,16 @@ def for_nhs_number(nhs_number: str, field_location: str):
234244
"""
235245
if not nhs_number_mod11_check(nhs_number):
236246
raise ValueError(f"{field_location} is not a valid NHS number")
247+
248+
@staticmethod
249+
def check_if_future_date(parsed_value: date | datetime):
250+
"""
251+
Ensure a parsed date or datetime object is not in the future.
252+
"""
253+
if isinstance(parsed_value, datetime):
254+
now = datetime.now(parsed_value.tzinfo) if parsed_value.tzinfo else datetime.now()
255+
elif isinstance(parsed_value, date):
256+
now = datetime.now().date()
257+
if parsed_value > now:
258+
return True
259+
return False

backend/tests/test_immunization_pre_validator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1104,7 +1104,7 @@ def test_pre_validate_lot_number(self):
11041104

11051105
def test_pre_validate_expiration_date(self):
11061106
"""Test pre_validate_expiration_date accepts valid values and rejects invalid values"""
1107-
ValidatorModelTests.test_date_value(self, field_location="expirationDate")
1107+
ValidatorModelTests.test_date_value(self, field_location="expirationDate", is_future_date_allowed=True)
11081108

11091109
def test_pre_validate_site_coding(self):
11101110
"""Test pre_validate_site_coding accepts valid values and rejects invalid values"""

backend/tests/utils/pre_validation_test_utils.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,7 @@ def test_unique_list(
282282
def test_date_value(
283283
test_instance: unittest.TestCase,
284284
field_location: str,
285+
is_future_date_allowed: bool = False,
285286
):
286287
"""
287288
Test that a FHIR model accepts valid date values and rejects the following invalid values:
@@ -314,6 +315,15 @@ def test_date_value(
314315
invalid_value=invalid_date_format,
315316
expected_error_message=f"{field_location} must be a valid date string in the " + 'format "YYYY-MM-DD"',
316317
)
318+
if not is_future_date_allowed:
319+
for invalid_date_format in InvalidValues.for_future_dates:
320+
test_invalid_values_rejected(
321+
test_instance,
322+
valid_json_data,
323+
field_location=field_location,
324+
invalid_value=invalid_date_format,
325+
expected_error_message=f"{field_location} must not be in the future",
326+
)
317327

318328
@staticmethod
319329
def test_date_time_value(
@@ -333,11 +343,14 @@ def test_date_time_value(
333343
"- 'YYYY-MM-DD' — Full date only"
334344
"- 'YYYY-MM-DDThh:mm:ss%z' — Full date and time with timezone (e.g. +00:00 or +01:00)"
335345
"- 'YYYY-MM-DDThh:mm:ss.f%z' — Full date and time with milliseconds and timezone"
346+
"- Date must not be in the future."
336347
)
337348

338349
if is_occurrence_date_time:
339-
expected_error_message += "Only '+00:00' and '+01:00' are accepted as valid timezone offsets.\n"
340-
expected_error_message += f"Note that partial dates are not allowed for {field_location} in this service."
350+
expected_error_message += (
351+
"Only '+00:00' and '+01:00' are accepted as valid timezone offsets.\n"
352+
f"Note that partial dates are not allowed for {field_location} in this service.\n"
353+
)
341354
valid_datetime_formats = ValidValues.for_date_times_strict_timezones
342355
invalid_datetime_formats = InvalidValues.for_date_time_string_formats_for_strict_timezone
343356
else:

backend/tests/utils/values_for_tests.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ class ValidValues:
3434
for_date_times_strict_timezones = [
3535
"2000-01-01", # Full date only
3636
"2000-01-01T00:00:00+00:00", # Time and offset all zeroes
37-
"2025-05-20T18:26:30+01:00", # Date with Time with no milliseconds and positive offset
37+
"2025-09-24T11:04:30+01:00", # Date with Time with no milliseconds and positive offset
3838
"2000-01-01T00:00:00+01:00", # Time and offset all zeroes
3939
"1933-12-31T11:11:11+01:00", # Positive offset (with hours and minutes not 0)
4040
"1933-12-31T11:11:11.1+00:00", # DateTime with milliseconds to 1 decimal place
@@ -291,6 +291,12 @@ class InvalidValues:
291291
"2000-02-30", # Invalid combination of month and day
292292
]
293293

294+
for_future_dates = [
295+
"2100-01-01", # Year in future
296+
"2050-12-31", # Year in future
297+
"2029-06-15", # Year in future
298+
]
299+
294300
# Strings which are not in acceptable date time format
295301
for_date_time_string_formats_for_relaxed_timezone = [
296302
"", # Empty string

0 commit comments

Comments
 (0)