-
Notifications
You must be signed in to change notification settings - Fork 4
Future Date Validation #841
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
389b63a
7d9c772
4446d87
5c22a2e
c6313df
dc4f5a8
1bfeaca
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ | |
| from datetime import datetime, timedelta | ||
| from decimal import Decimal | ||
| from typing import Union | ||
| from datetime import datetime, date | ||
|
|
||
| from .generic_utils import nhs_number_mod11_check, is_valid_simple_snomed | ||
|
|
||
|
|
@@ -82,7 +83,7 @@ def for_list( | |
| raise ValueError(f"{field_location} must be an array of non-empty objects") | ||
|
|
||
| @staticmethod | ||
| def for_date(field_value: str, field_location: str): | ||
| def for_date(field_value: str, field_location: str, future_date_allowed: bool = False): | ||
| """ | ||
| Apply pre-validation to a date field to ensure that it is a string (JSON dates must be | ||
| 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): | |
| raise TypeError(f"{field_location} must be a string") | ||
|
|
||
| try: | ||
| datetime.strptime(field_value, "%Y-%m-%d").date() | ||
| parsed_date = datetime.strptime(field_value, "%Y-%m-%d").date() | ||
| except ValueError as value_error: | ||
| raise ValueError( | ||
| f'{field_location} must be a valid date string in the format "YYYY-MM-DD"' | ||
| ) from value_error | ||
|
|
||
| # Enforce future date rule using central checker after successful parse | ||
| if not future_date_allowed and PreValidation.check_if_future_date(parsed_date): | ||
| raise ValueError(f"{field_location} must not be in the future") | ||
|
|
||
| @staticmethod | ||
| def for_date_time(field_value: str, field_location: str, strict_timezone: bool = True): | ||
| """ | ||
|
|
@@ -116,11 +121,13 @@ def for_date_time(field_value: str, field_location: str, strict_timezone: bool = | |
| "- 'YYYY-MM-DD' — Full date only" | ||
| "- 'YYYY-MM-DDThh:mm:ss%z' — Full date and time with timezone (e.g. +00:00 or +01:00)" | ||
| "- 'YYYY-MM-DDThh:mm:ss.f%z' — Full date and time with milliseconds and timezone" | ||
| "- Date must not be in the future." | ||
| ) | ||
|
|
||
| if strict_timezone: | ||
| error_message += "Only '+00:00' and '+01:00' are accepted as valid timezone offsets.\n" | ||
| error_message += f"Note that partial dates are not allowed for {field_location} in this service." | ||
| error_message += ( | ||
| "Only '+00:00' and '+01:00' are accepted as valid timezone offsets.\n" | ||
| f"Note that partial dates are not allowed for {field_location} in this service.\n" | ||
| ) | ||
|
|
||
| allowed_suffixes = {"+00:00", "+01:00", "+0000", "+0100",} | ||
|
|
||
|
|
@@ -133,10 +140,13 @@ def for_date_time(field_value: str, field_location: str, strict_timezone: bool = | |
| for fmt in formats: | ||
| try: | ||
| fhir_date = datetime.strptime(field_value, fmt) | ||
|
|
||
| # Enforce future-date rule using central checker after successful parse | ||
| if PreValidation.check_if_future_date(fhir_date): | ||
| raise ValueError(f"{field_location} must not be in the future") | ||
| # After successful parse, enforce timezone and future-date rules | ||
| if strict_timezone and fhir_date.tzinfo is not None: | ||
| if not any(field_value.endswith(suffix) for suffix in allowed_suffixes): | ||
| raise ValueError(error_message) | ||
| if not any(field_value.endswith(suffix) for suffix in allowed_suffixes): | ||
| raise ValueError(error_message) | ||
| return fhir_date.isoformat() | ||
| except ValueError: | ||
| continue | ||
|
|
@@ -234,3 +244,16 @@ def for_nhs_number(nhs_number: str, field_location: str): | |
| """ | ||
| if not nhs_number_mod11_check(nhs_number): | ||
| raise ValueError(f"{field_location} is not a valid NHS number") | ||
|
|
||
| @staticmethod | ||
| def check_if_future_date(parsed_value: date | datetime): | ||
| """ | ||
| Ensure a parsed date or datetime object is not in the future. | ||
| """ | ||
| if isinstance(parsed_value, datetime): | ||
| now = datetime.now(parsed_value.tzinfo) if parsed_value.tzinfo else datetime.now() | ||
| elif isinstance(parsed_value, date): | ||
| now = datetime.now().date() | ||
| if parsed_value > now: | ||
| return True | ||
| return False | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor, but could use a newline. |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -34,7 +34,7 @@ class ValidValues: | |
| for_date_times_strict_timezones = [ | ||
| "2000-01-01", # Full date only | ||
| "2000-01-01T00:00:00+00:00", # Time and offset all zeroes | ||
| "2025-05-20T18:26:30+01:00", # Date with Time with no milliseconds and positive offset | ||
| "2025-09-24T11:04:30+01:00", # Date with Time with no milliseconds and positive offset | ||
| "2000-01-01T00:00:00+01:00", # Time and offset all zeroes | ||
| "1933-12-31T11:11:11+01:00", # Positive offset (with hours and minutes not 0) | ||
| "1933-12-31T11:11:11.1+00:00", # DateTime with milliseconds to 1 decimal place | ||
|
|
@@ -291,6 +291,12 @@ class InvalidValues: | |
| "2000-02-30", # Invalid combination of month and day | ||
| ] | ||
|
|
||
| for_future_dates = [ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not essential, but the future-proof way of doing this would be to generate date strings with strptime() based on offsets from the time now. That would mean we can have reliable test cases for dates which were only months or days in the future as well
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm sorry, only managed to get to review this now due to some internal calls. This is absolutely mandatory. Even if we are deferring until the year 3000, you never write unit tests that are going to fail at some point and need bumping. Typically, I would advocate mocking datetime.now(), but the validation tests are such spaghetti, that James' suggestion to use time offsets so we can guarantee it is always a future date would be sufficient. |
||
| "2100-01-01", # Year in future | ||
| "2050-12-31", # Year in future | ||
| "2029-06-15", # Year in future | ||
| ] | ||
|
|
||
| # Strings which are not in acceptable date time format | ||
| for_date_time_string_formats_for_relaxed_timezone = [ | ||
| "", # Empty string | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This code will crash if parsed_value is not a datetime or date and
nowwill not be assigned a value. I have checked and your code is always called in a safe way - i.e. it is only ever provided a datetime or date.However, it might be worth stipulating this can only be called by functions which guarantee the input is date | datetime. Or handling the case where it isn't. Probably the first option is simplest and okay.