11import datetime
22import re
33import uuid
4- from typing import Optional
4+ from typing import Decimal , Optional , Union
55
66from common .validator .constants .enums import MESSAGES , ExceptionLevels , MessageLabel
77from common .validator .error_report .record_error import ErrorReport , RecordError
@@ -26,14 +26,22 @@ def validate_expression(
2626 self , expression_type : str , expression_rule : str , field_name : str , field_value : str , row : dict
2727 ) -> ErrorReport :
2828 match expression_type :
29- case "DATETIME" :
30- return self ._validate_datetime (expression_rule , field_name , field_value , row )
3129 case "STRING" :
32- return self ._validate_for_string_values (expression_rule , field_name , field_value , row )
30+ return self .validation_for_string_values (expression_rule , field_name , field_value , row )
3331 case "LIST" :
34- return self ._validate_for_list_values (expression_rule , field_name , field_value , row )
32+ return self .validation_for_list (expression_rule , field_name , field_value , row )
3533 case "DATE" :
36- return self .validate_for_date (expression_rule , field_name , field_value , row )
34+ return self .validation_for_date (expression_rule , field_name , field_value , row )
35+ case "DATETIME" :
36+ return self .validation_for_date_time (expression_rule , field_name , field_value , row )
37+ case "POSITIVEINTEGER" :
38+ return self .validation_for_positive_integer (expression_rule , field_name , field_value , row )
39+ case "UNIQUELIST" :
40+ return self .validation_for_unique_list (expression_rule , field_name , field_value , row )
41+ case "BOOLEAN" :
42+ return self ._validate_boolean (expression_rule , field_name , field_value , row )
43+ case "INTDECIMAL" :
44+ return self .validation_for_integer_or_decimal (expression_rule , field_name , field_value , row )
3745 case "UUID" :
3846 return self ._validate_uuid (expression_rule , field_name , field_value , row )
3947 case "INT" :
@@ -86,22 +94,7 @@ def validate_expression(
8694 return "Schema expression not found! Check your expression type : " + expression_type
8795
8896 # ISO 8601 date/datetime validate (currently date-only)
89- def _validate_datetime (self , _expression_rule , field_name , field_value , row ) -> ErrorReport :
90- try :
91- # Current behavior expects date-only; datetime raises and is handled below
92- datetime .date .fromisoformat (field_value )
93- except RecordError as e :
94- code = e .code if e .code is not None else ExceptionLevels .RECORD_CHECK_FAILED
95- message = e .message if e .message is not None else MESSAGES [ExceptionLevels .RECORD_CHECK_FAILED ]
96- if e .details is not None :
97- details = e .details
98- return ErrorReport (code , message , row , field_name , details , self .summarise )
99- except Exception as e :
100- if self .report_unexpected_exception :
101- message = MESSAGES [ExceptionLevels .UNEXPECTED_EXCEPTION ] % (e .__class__ .__name__ , e )
102- return ErrorReport (ExceptionLevels .UNEXPECTED_EXCEPTION , message , row , field_name , "" , self .summarise )
103-
104- def validate_for_date (self , _expression_rule , field_name , field_value , row , future_date_allowed : bool = False ):
97+ def validation_for_date (self , _expression_rule , field_name , field_value , row , future_date_allowed : bool = False ):
10598 """
10699 Apply pre-validation to a date field to ensure that it is a string (JSON dates must be
107100 written as strings) containing a valid date in the format "YYYY-MM-DD"
@@ -133,53 +126,51 @@ def _validate_uuid(self, _expression_rule: str, field_name: str, field_value: st
133126 message = MESSAGES [ExceptionLevels .UNEXPECTED_EXCEPTION ] % (e .__class__ .__name__ , e )
134127 return ErrorReport (ExceptionLevels .UNEXPECTED_EXCEPTION , message , row , field_name , "" , self .summarise )
135128
136- # Integer Validate
137- def _validate_integer (self , expression_rule : str , field_name : str , field_value : str , row : dict ) -> ErrorReport :
138- try :
139- int (field_value )
140- if expression_rule :
141- check_value = int (expression_rule )
142- if int (field_value ) != check_value :
143- raise RecordError (
144- ExceptionLevels .RECORD_CHECK_FAILED ,
145- "Value integer check failed" ,
146- MessageLabel .VALUE_MISMATCH_MSG
147- + MessageLabel .EXPECTED_LABEL
148- + expression_rule
149- + " "
150- + MessageLabel .FOUND_LABEL
151- + field_value ,
152- )
153- except RecordError as e :
154- code = e .code if e .code is not None else ExceptionLevels .RECORD_CHECK_FAILED
155- message = e .message if e .message is not None else MESSAGES [ExceptionLevels .RECORD_CHECK_FAILED ]
156- if e .details is not None :
157- details = e .details
158- return ErrorReport (code , message , row , field_name , details , self .summarise )
159- except Exception as e :
160- if self .report_unexpected_exception :
161- message = MESSAGES [ExceptionLevels .UNEXPECTED_EXCEPTION ] % (e .__class__ .__name__ , e )
162- return ErrorReport (ExceptionLevels .UNEXPECTED_EXCEPTION , message , row , field_name , "" , self .summarise )
129+ def validation_for_positive_integer (field_value : int , field_name : str , max_value : int = None ):
130+ """
131+ Apply pre-validation to an integer field to ensure that it is a positive integer,
132+ which does not exceed the maximum allowed value (if applicable)
133+ """
134+ # This check uses type() instead of isinstance() because bool is a subclass of int.
135+ if type (field_value ) is not int : # pylint: disable=unidiomatic-typecheck
136+ raise TypeError (f"{ field_name } must be a positive integer" )
163137
164- # Length Validate
165- def _validate_length (self , expression_rule : str , field_name : str , field_value : str , row : dict ) -> ErrorReport :
166- try :
167- str_len = len (field_value )
168- check_length = int (expression_rule )
169- if str_len > check_length :
170- raise RecordError (
171- ExceptionLevels .RECORD_CHECK_FAILED , "Value length check failed" , "Value is longer than expected"
138+ if field_value <= 0 :
139+ raise ValueError (f"{ field_name } must be a positive integer" )
140+
141+ if max_value :
142+ if field_value > max_value :
143+ raise ValueError (f"{ field_name } must be an integer in the range 1 to { max_value } " )
144+
145+ def validation_for_integer_or_decimal (field_value : Union [int , Decimal ], field_location : str ):
146+ """
147+ Apply pre-validation to a decimal field to ensure that it is an integer or decimal,
148+ which does not exceed the maximum allowed number of decimal places (if applicable)
149+ """
150+ if not (
151+ # This check uses type() instead of isinstance() because bool is a subclass of int.
152+ type (field_value ) is int # pylint: disable=unidiomatic-typecheck
153+ or type (field_value ) is Decimal # pylint: disable=unidiomatic-typecheck
154+ ):
155+ raise TypeError (f"{ field_location } must be a number" )
156+
157+ def validation_for_unique_list (
158+ list_to_check : list ,
159+ unique_value_in_list : str ,
160+ field_location : str ,
161+ ):
162+ """
163+ Apply pre-validation to a list of dictionaries to ensure that a specified value in each
164+ dictionary is unique across the list
165+ """
166+ found = []
167+ for item in list_to_check :
168+ if item [unique_value_in_list ] in found :
169+ raise ValueError (
170+ f"{ field_location .replace ('FIELD_TO_REPLACE' , item [unique_value_in_list ])} " + " must be unique"
172171 )
173- except RecordError as e :
174- code = e .code if e .code is not None else ExceptionLevels .RECORD_CHECK_FAILED
175- message = e .message if e .message is not None else MESSAGES [ExceptionLevels .RECORD_CHECK_FAILED ]
176- if e .details is not None :
177- details = e .details
178- return ErrorReport (code , message , row , field_name , details , self .summarise )
179- except Exception as e :
180- if self .report_unexpected_exception :
181- message = MESSAGES [ExceptionLevels .UNEXPECTED_EXCEPTION ] % (e .__class__ .__name__ , e )
182- return ErrorReport (ExceptionLevels .UNEXPECTED_EXCEPTION , message , row , field_name , "" , self .summarise )
172+
173+ found .append (item [unique_value_in_list ])
183174
184175 # Regex Validate
185176 def _validate_regex (self , expression_rule : str , field_name : str , field_value : str , row : dict ) -> ErrorReport :
@@ -202,32 +193,12 @@ def _validate_regex(self, expression_rule: str, field_name: str, field_value: st
202193 message = MESSAGES [ExceptionLevels .UNEXPECTED_EXCEPTION ] % (e .__class__ .__name__ , e )
203194 return ErrorReport (ExceptionLevels .UNEXPECTED_EXCEPTION , message , row , field_name , "" , self .summarise )
204195
205- # Equal Validate
206- def _validate_equal (self , expression_rule : str , field_name : str , field_value : str , row : dict ) -> ErrorReport :
207- try :
208- if field_value != expression_rule :
209- raise RecordError (
210- ExceptionLevels .RECORD_CHECK_FAILED ,
211- "Value equals check failed" ,
212- MessageLabel .VALUE_MISMATCH_MSG
213- + MessageLabel .EXPECTED_LABEL
214- + expression_rule
215- + " "
216- + MessageLabel .FOUND_LABEL
217- + field_value ,
218- )
219- except RecordError as e :
220- code = e .code if e .code is not None else ExceptionLevels .RECORD_CHECK_FAILED
221- message = e .message if e .message is not None else MESSAGES [ExceptionLevels .RECORD_CHECK_FAILED ]
222- if e .details is not None :
223- details = e .details
224- return ErrorReport (code , message , row , field_name , details , self .summarise )
225- except Exception as e :
226- if self .report_unexpected_exception :
227- message = MESSAGES [ExceptionLevels .UNEXPECTED_EXCEPTION ] % (e .__class__ .__name__ , e )
228- return ErrorReport (ExceptionLevels .UNEXPECTED_EXCEPTION , message , row , field_name , "" , self .summarise )
196+ def validation_for_boolean (field_value : str , field_name : str ):
197+ """Apply pre-validation to a boolean field to ensure that it is a boolean"""
198+ if not isinstance (field_value , bool ):
199+ raise TypeError (f"{ field_name } must be a boolean" )
229200
230- def for_list (self , expression_rule : str , field_name : str , field_value : list , row : dict ):
201+ def validation_for_list (self , expression_rule : str , field_name : str , field_value : list , row : dict ):
231202 """
232203 Apply validation to a list field to ensure it is a non-empty list which meets the length requirements and
233204 requirements, if applicable, for each list element to be a non-empty string or non-empty dictionary
@@ -263,6 +234,62 @@ def for_list(self, expression_rule: str, field_name: str, field_value: list, row
263234 if len (element ) == 0 :
264235 raise ValueError (f"{ field_name } must be an array of non-empty objects" )
265236
237+ def validation_for_date_time (field_value : str , field_location : str , strict_timezone : bool = True ):
238+ """
239+ Apply pre-validation to a datetime field to ensure that it is a string (JSON dates must be written as strings)
240+ containing a valid datetime. Note that partial dates are valid for FHIR, but are not allowed for this API.
241+ Valid formats are any of the following:
242+ * 'YYYY-MM-DD' - Full date only
243+ * 'YYYY-MM-DDThh:mm:ss%z' - Full date, time without milliseconds, timezone
244+ * 'YYYY-MM-DDThh:mm:ss.f%z' - Full date, time with milliseconds (any level of precision), timezone
245+ """
246+
247+ if not isinstance (field_value , str ):
248+ raise TypeError (f"{ field_location } must be a string" )
249+
250+ error_message = (
251+ f"{ field_location } must be a valid datetime in one of the following formats:"
252+ "- 'YYYY-MM-DD' — Full date only"
253+ "- 'YYYY-MM-DDThh:mm:ss%z' — Full date and time with timezone (e.g. +00:00 or +01:00)"
254+ "- 'YYYY-MM-DDThh:mm:ss.f%z' — Full date and time with milliseconds and timezone"
255+ "- Date must not be in the future."
256+ )
257+ if strict_timezone :
258+ error_message += (
259+ "Only '+00:00' and '+01:00' are accepted as valid timezone offsets.\n "
260+ f"Note that partial dates are not allowed for { field_location } in this service.\n "
261+ )
262+
263+ allowed_suffixes = {
264+ "+00:00" ,
265+ "+01:00" ,
266+ "+0000" ,
267+ "+0100" ,
268+ }
269+
270+ # List of accepted strict formats
271+ formats = [
272+ "%Y-%m-%d" ,
273+ "%Y-%m-%dT%H:%M:%S%z" ,
274+ "%Y-%m-%dT%H:%M:%S.%f%z" ,
275+ ]
276+
277+ for fmt in formats :
278+ try :
279+ fhir_date = datetime .strptime (field_value , fmt )
280+ # Enforce future-date rule using central checker after successful parse
281+ if check_if_future_date (fhir_date ):
282+ raise ValueError (f"{ field_location } must not be in the future" )
283+ # After successful parse, enforce timezone and future-date rules
284+ if strict_timezone and fhir_date .tzinfo is not None :
285+ if not any (field_value .endswith (suffix ) for suffix in allowed_suffixes ):
286+ raise ValueError (error_message )
287+ return fhir_date .isoformat ()
288+ except ValueError :
289+ continue
290+
291+ raise ValueError (error_message )
292+
266293 # Not Equal Validate
267294 def _validate_not_equal (self , expression_rule : str , field_name : str , field_value : str , row : dict ) -> ErrorReport :
268295 try :
@@ -471,7 +498,7 @@ def _validate_empty(self, _expression_rule: str, field_name: str, field_value: s
471498 return ErrorReport (ExceptionLevels .UNEXPECTED_EXCEPTION , message , row , field_name , "" , self .summarise )
472499
473500 # String Pre-Validation
474- def _validate_for_string_values (
501+ def validation_for_string_values (
475502 self , _expression_rule : str , field_name : str , field_value : str , row : dict
476503 ) -> ErrorReport :
477504 """
0 commit comments