Skip to content

Commit df95250

Browse files
committed
Future Date Validation (#841)
* Datetime must not be in the future
1 parent 11f7d1b commit df95250

File tree

5 files changed

+53
-12
lines changed

5 files changed

+53
-12
lines changed

backend/src/models/fhir_immunization_pre_validators.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -737,7 +737,7 @@ def pre_validate_expiration_date(self, values: dict) -> dict:
737737
"""
738738
try:
739739
field_value = values["expirationDate"]
740-
PreValidation.for_date(field_value, "expirationDate")
740+
PreValidation.for_date(field_value, "expirationDate", future_date_allowed=True)
741741
except KeyError:
742742
pass
743743

backend/src/models/utils/pre_validator_utils.py

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from datetime import datetime
1+
from datetime import date, datetime
22
from decimal import Decimal
33
from typing import Union
44

@@ -80,7 +80,7 @@ def for_list(
8080
raise ValueError(f"{field_location} must be an array of non-empty objects")
8181

8282
@staticmethod
83-
def for_date(field_value: str, field_location: str):
83+
def for_date(field_value: str, field_location: str, future_date_allowed: bool = False):
8484
"""
8585
Apply pre-validation to a date field to ensure that it is a string (JSON dates must be
8686
written as strings) containing a valid date in the format "YYYY-MM-DD"
@@ -89,10 +89,14 @@ def for_date(field_value: str, field_location: str):
8989
raise TypeError(f"{field_location} must be a string")
9090

9191
try:
92-
datetime.strptime(field_value, "%Y-%m-%d").date()
92+
parsed_date = datetime.strptime(field_value, "%Y-%m-%d").date()
9393
except ValueError as value_error:
9494
raise ValueError(f'{field_location} must be a valid date string in the format "YYYY-MM-DD"') from value_error
9595

96+
# Enforce future date rule using central checker after successful parse
97+
if not future_date_allowed and PreValidation.check_if_future_date(parsed_date):
98+
raise ValueError(f"{field_location} must not be in the future")
99+
96100
@staticmethod
97101
def for_date_time(field_value: str, field_location: str, strict_timezone: bool = True):
98102
"""
@@ -112,11 +116,13 @@ def for_date_time(field_value: str, field_location: str, strict_timezone: bool =
112116
"- 'YYYY-MM-DD' — Full date only"
113117
"- 'YYYY-MM-DDThh:mm:ss%z' — Full date and time with timezone (e.g. +00:00 or +01:00)"
114118
"- 'YYYY-MM-DDThh:mm:ss.f%z' — Full date and time with milliseconds and timezone"
119+
"- Date must not be in the future."
115120
)
116-
117121
if strict_timezone:
118-
error_message += "Only '+00:00' and '+01:00' are accepted as valid timezone offsets.\n"
119-
error_message += f"Note that partial dates are not allowed for {field_location} in this service."
122+
error_message += (
123+
"Only '+00:00' and '+01:00' are accepted as valid timezone offsets.\n"
124+
f"Note that partial dates are not allowed for {field_location} in this service.\n"
125+
)
120126

121127
allowed_suffixes = {
122128
"+00:00",
@@ -135,7 +141,10 @@ def for_date_time(field_value: str, field_location: str, strict_timezone: bool =
135141
for fmt in formats:
136142
try:
137143
fhir_date = datetime.strptime(field_value, fmt)
138-
144+
# Enforce future-date rule using central checker after successful parse
145+
if PreValidation.check_if_future_date(fhir_date):
146+
raise ValueError(f"{field_location} must not be in the future")
147+
# After successful parse, enforce timezone and future-date rules
139148
if strict_timezone and fhir_date.tzinfo is not None:
140149
if not any(field_value.endswith(suffix) for suffix in allowed_suffixes):
141150
raise ValueError(error_message)
@@ -233,3 +242,16 @@ def for_nhs_number(nhs_number: str, field_location: str):
233242
"""
234243
if not nhs_number_mod11_check(nhs_number):
235244
raise ValueError(f"{field_location} is not a valid NHS number")
245+
246+
@staticmethod
247+
def check_if_future_date(parsed_value: date | datetime):
248+
"""
249+
Ensure a parsed date or datetime object is not in the future.
250+
"""
251+
if isinstance(parsed_value, datetime):
252+
now = datetime.now(parsed_value.tzinfo) if parsed_value.tzinfo else datetime.now()
253+
elif isinstance(parsed_value, date):
254+
now = datetime.now().date()
255+
if parsed_value > now:
256+
return True
257+
return False

backend/tests/test_immunization_pre_validator.py

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

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

11081108
def test_pre_validate_site_coding(self):
11091109
"""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
@@ -281,6 +281,7 @@ def test_unique_list(
281281
def test_date_value(
282282
test_instance: unittest.TestCase,
283283
field_location: str,
284+
is_future_date_allowed: bool = False,
284285
):
285286
"""
286287
Test that a FHIR model accepts valid date values and rejects the following invalid values:
@@ -313,6 +314,15 @@ def test_date_value(
313314
invalid_value=invalid_date_format,
314315
expected_error_message=f"{field_location} must be a valid date string in the " + 'format "YYYY-MM-DD"',
315316
)
317+
if not is_future_date_allowed:
318+
for invalid_date_format in InvalidValues.for_future_dates:
319+
test_invalid_values_rejected(
320+
test_instance,
321+
valid_json_data,
322+
field_location=field_location,
323+
invalid_value=invalid_date_format,
324+
expected_error_message=f"{field_location} must not be in the future",
325+
)
316326

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

337348
if is_occurrence_date_time:
338-
expected_error_message += "Only '+00:00' and '+01:00' are accepted as valid timezone offsets.\n"
339-
expected_error_message += f"Note that partial dates are not allowed for {field_location} in this service."
349+
expected_error_message += (
350+
"Only '+00:00' and '+01:00' are accepted as valid timezone offsets.\n"
351+
f"Note that partial dates are not allowed for {field_location} in this service.\n"
352+
)
340353
valid_datetime_formats = ValidValues.for_date_times_strict_timezones
341354
invalid_datetime_formats = InvalidValues.for_date_time_string_formats_for_strict_timezone
342355
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)