diff --git a/.gitignore b/.gitignore index 605d3f8..108ef9d 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ cover/ # Build dist/ __pycache__/ + +# Temp +agents.md diff --git a/poetry.lock b/poetry.lock index a8ed12a..31d205d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -742,6 +742,70 @@ files = [ {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, ] +[[package]] +name = "lxml" +version = "4.6.3" +description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, != 3.4.*" +groups = ["main"] +files = [ + {file = "lxml-4.6.3-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:df7c53783a46febb0e70f6b05df2ba104610f2fb0d27023409734a3ecbb78fb2"}, + {file = "lxml-4.6.3-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:1b7584d421d254ab86d4f0b13ec662a9014397678a7c4265a02a6d7c2b18a75f"}, + {file = "lxml-4.6.3-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:079f3ae844f38982d156efce585bc540c16a926d4436712cf4baee0cce487a3d"}, + {file = "lxml-4.6.3-cp27-cp27m-win32.whl", hash = "sha256:bc4313cbeb0e7a416a488d72f9680fffffc645f8a838bd2193809881c67dd106"}, + {file = "lxml-4.6.3-cp27-cp27m-win_amd64.whl", hash = "sha256:8157dadbb09a34a6bd95a50690595e1fa0af1a99445e2744110e3dca7831c4ee"}, + {file = "lxml-4.6.3-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:7728e05c35412ba36d3e9795ae8995e3c86958179c9770e65558ec3fdfd3724f"}, + {file = "lxml-4.6.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:4bff24dfeea62f2e56f5bab929b4428ae6caba2d1eea0c2d6eb618e30a71e6d4"}, + {file = "lxml-4.6.3-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:64812391546a18896adaa86c77c59a4998f33c24788cadc35789e55b727a37f4"}, + {file = "lxml-4.6.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:c1a40c06fd5ba37ad39caa0b3144eb3772e813b5fb5b084198a985431c2f1e8d"}, + {file = "lxml-4.6.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:74f7d8d439b18fa4c385f3f5dfd11144bb87c1da034a466c5b5577d23a1d9b51"}, + {file = "lxml-4.6.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f90ba11136bfdd25cae3951af8da2e95121c9b9b93727b1b896e3fa105b2f586"}, + {file = "lxml-4.6.3-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:4c61b3a0db43a1607d6264166b230438f85bfed02e8cff20c22e564d0faff354"}, + {file = "lxml-4.6.3-cp35-cp35m-manylinux2014_x86_64.whl", hash = "sha256:5c8c163396cc0df3fd151b927e74f6e4acd67160d6c33304e805b84293351d16"}, + {file = "lxml-4.6.3-cp35-cp35m-win32.whl", hash = "sha256:f2380a6376dfa090227b663f9678150ef27543483055cc327555fb592c5967e2"}, + {file = "lxml-4.6.3-cp35-cp35m-win_amd64.whl", hash = "sha256:c4f05c5a7c49d2fb70223d0d5bcfbe474cf928310ac9fa6a7c6dddc831d0b1d4"}, + {file = "lxml-4.6.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d2e35d7bf1c1ac8c538f88d26b396e73dd81440d59c1ef8522e1ea77b345ede4"}, + {file = "lxml-4.6.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:289e9ca1a9287f08daaf796d96e06cb2bc2958891d7911ac7cae1c5f9e1e0ee3"}, + {file = "lxml-4.6.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:bccbfc27563652de7dc9bdc595cb25e90b59c5f8e23e806ed0fd623755b6565d"}, + {file = "lxml-4.6.3-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:d916d31fd85b2f78c76400d625076d9124de3e4bda8b016d25a050cc7d603f24"}, + {file = "lxml-4.6.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:820628b7b3135403540202e60551e741f9b6d3304371712521be939470b454ec"}, + {file = "lxml-4.6.3-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:c47ff7e0a36d4efac9fd692cfa33fbd0636674c102e9e8d9b26e1b93a94e7617"}, + {file = "lxml-4.6.3-cp36-cp36m-win32.whl", hash = "sha256:5a0a14e264069c03e46f926be0d8919f4105c1623d620e7ec0e612a2e9bf1c04"}, + {file = "lxml-4.6.3-cp36-cp36m-win_amd64.whl", hash = "sha256:92e821e43ad382332eade6812e298dc9701c75fe289f2a2d39c7960b43d1e92a"}, + {file = "lxml-4.6.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:efd7a09678fd8b53117f6bae4fa3825e0a22b03ef0a932e070c0bdbb3a35e654"}, + {file = "lxml-4.6.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:efac139c3f0bf4f0939f9375af4b02c5ad83a622de52d6dfa8e438e8e01d0eb0"}, + {file = "lxml-4.6.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:0fbcf5565ac01dff87cbfc0ff323515c823081c5777a9fc7703ff58388c258c3"}, + {file = "lxml-4.6.3-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:36108c73739985979bf302006527cf8a20515ce444ba916281d1c43938b8bb96"}, + {file = "lxml-4.6.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:122fba10466c7bd4178b07dba427aa516286b846b2cbd6f6169141917283aae2"}, + {file = "lxml-4.6.3-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:cdaf11d2bd275bf391b5308f86731e5194a21af45fbaaaf1d9e8147b9160ea92"}, + {file = "lxml-4.6.3-cp37-cp37m-win32.whl", hash = "sha256:3439c71103ef0e904ea0a1901611863e51f50b5cd5e8654a151740fde5e1cade"}, + {file = "lxml-4.6.3-cp37-cp37m-win_amd64.whl", hash = "sha256:4289728b5e2000a4ad4ab8da6e1db2e093c63c08bdc0414799ee776a3f78da4b"}, + {file = "lxml-4.6.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b007cbb845b28db4fb8b6a5cdcbf65bacb16a8bd328b53cbc0698688a68e1caa"}, + {file = "lxml-4.6.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:76fa7b1362d19f8fbd3e75fe2fb7c79359b0af8747e6f7141c338f0bee2f871a"}, + {file = "lxml-4.6.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:26e761ab5b07adf5f555ee82fb4bfc35bf93750499c6c7614bd64d12aaa67927"}, + {file = "lxml-4.6.3-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:e1cbd3f19a61e27e011e02f9600837b921ac661f0c40560eefb366e4e4fb275e"}, + {file = "lxml-4.6.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:66e575c62792c3f9ca47cb8b6fab9e35bab91360c783d1606f758761810c9791"}, + {file = "lxml-4.6.3-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:1b38116b6e628118dea5b2186ee6820ab138dbb1e24a13e478490c7db2f326ae"}, + {file = "lxml-4.6.3-cp38-cp38-win32.whl", hash = "sha256:89b8b22a5ff72d89d48d0e62abb14340d9e99fd637d046c27b8b257a01ffbe28"}, + {file = "lxml-4.6.3-cp38-cp38-win_amd64.whl", hash = "sha256:2a9d50e69aac3ebee695424f7dbd7b8c6d6eb7de2a2eb6b0f6c7db6aa41e02b7"}, + {file = "lxml-4.6.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ce256aaa50f6cc9a649c51be3cd4ff142d67295bfc4f490c9134d0f9f6d58ef0"}, + {file = "lxml-4.6.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:7610b8c31688f0b1be0ef882889817939490a36d0ee880ea562a4e1399c447a1"}, + {file = "lxml-4.6.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f8380c03e45cf09f8557bdaa41e1fa7c81f3ae22828e1db470ab2a6c96d8bc23"}, + {file = "lxml-4.6.3-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:3082c518be8e97324390614dacd041bb1358c882d77108ca1957ba47738d9d59"}, + {file = "lxml-4.6.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:884ab9b29feaca361f7f88d811b1eea9bfca36cf3da27768d28ad45c3ee6f969"}, + {file = "lxml-4.6.3-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:6f12e1427285008fd32a6025e38e977d44d6382cf28e7201ed10d6c1698d2a9a"}, + {file = "lxml-4.6.3-cp39-cp39-win32.whl", hash = "sha256:33bb934a044cf32157c12bfcfbb6649807da20aa92c062ef51903415c704704f"}, + {file = "lxml-4.6.3-cp39-cp39-win_amd64.whl", hash = "sha256:542d454665a3e277f76954418124d67516c5f88e51a900365ed54a9806122b83"}, + {file = "lxml-4.6.3.tar.gz", hash = "sha256:39b78571b3b30645ac77b95f7c69d1bffc4cf8c3b157c435a34da72e78c82468"}, +] + +[package.extras] +cssselect = ["cssselect (>=0.7)"] +html5 = ["html5lib"] +htmlsoup = ["BeautifulSoup4"] +source = ["Cython (>=0.29.7)"] + [[package]] name = "markupsafe" version = "3.0.3" @@ -1157,6 +1221,18 @@ files = [ [package.extras] dev = ["black", "build", "mypy", "pytest", "pytest-cov", "setuptools", "tox", "twine", "wheel"] +[[package]] +name = "pytz" +version = "2020.4" +description = "World timezone definitions, modern and historical" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "pytz-2020.4-py2.py3-none-any.whl", hash = "sha256:5c55e189b682d420be27c6995ba6edce0c0a77dd67bfbe2ae6607134d5851ffd"}, + {file = "pytz-2020.4.tar.gz", hash = "sha256:3e6b7dd2d1e0a59084bcee14a17af60c5c562cdc16d828e8eba2e683d3a7e268"}, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1583,4 +1659,4 @@ test = ["pytest", "pytest-cov"] [metadata] lock-version = "2.1" python-versions = "==3.10.12" -content-hash = "eed83f4afb5a8ec608852863089568d79a5721872e413420d5ef147a759bc5d1" +content-hash = "d475a6b704ae97b14911df99e586164f4ba63337eba563645f2d2c143459568b" diff --git a/pyproject.toml b/pyproject.toml index 9ef4923..41a7473 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,15 @@ authors = [ ] readme = "README.md" requires-python = "==3.10.12" -dependencies = ["boto3 (>=1.26.159,<2.0.0)", "botocore (>=1.29.159,<1.30.0)", "simplejson (>=3.17.2,<4.0.0)", "python-dateutil (>=2.7.0.post0,<3.0.0)", "six (>=1.5,<2.0.0)"] +dependencies = [ + "boto3 (>=1.26.159,<2.0.0)", + "botocore (>=1.29.159,<1.30.0)", + "lxml (==4.6.3)", + "python-dateutil (>=2.7.0.post0,<3.0.0)", + "pytz (==2020.4)", + "simplejson (>=3.17.2,<4.0.0)", + "six (>=1.5,<2.0.0)" +] [tool.poetry] packages = [{include = "eps_spine_shared", from = "src"}] diff --git a/src/eps_spine_shared/common/checksum_util.py b/src/eps_spine_shared/common/checksum_util.py new file mode 100644 index 0000000..7b233c2 --- /dev/null +++ b/src/eps_spine_shared/common/checksum_util.py @@ -0,0 +1,64 @@ +from string import ascii_uppercase + +LONGIDLENGTH_WITH_CHECKDIGIT = 37 +SHORTIDLENGTH_WITH_CHECKDIGIT = 20 + + +def calculate_checksum(prescription_id): + """ + Generate a checksum for either R1 or R2 prescription + """ + prsc_id = prescription_id.replace("-", "") + prsc_id_length = len(prsc_id) + + running_total = 0 + for string_position in range(prsc_id_length - 1): + char_mod36 = int(prsc_id[string_position], 36) + running_total += char_mod36 * (2 ** (prsc_id_length - string_position - 1)) + + check_value = (38 - running_total % 37) % 37 + if check_value == 36: + check_value = "+" + elif check_value > 9: + check_value = ascii_uppercase[check_value - 10] + else: + check_value = str(check_value) + + return check_value + + +def check_checksum(prescription_id, internal_id, log_object): + """ + Check the checksum of a Prescription ID + :prescription_id the prescription to check + :log_object invalid checksums will be logged + """ + check_character = prescription_id[-1:] + check_value = calculate_checksum(prescription_id) + + if check_value == check_character: + return True + + log_object.write_log( + "MWS0042", + None, + { + "internalID": internal_id, + "prescriptionID": prescription_id, + "checkValue": check_value, + }, + ) + + return False + + +def remove_check_digit(prescription_id): + """ + Takes the passed in id and determines, by its length, if it contains a checkdigit, + returns an id without the check digit + """ + prescription_key = prescription_id + id_length = len(prescription_id) + if id_length in [LONGIDLENGTH_WITH_CHECKDIGIT, SHORTIDLENGTH_WITH_CHECKDIGIT]: + prescription_key = prescription_id[:-1] + return prescription_key diff --git a/src/eps_spine_shared/common/dynamodb_datastore.py b/src/eps_spine_shared/common/dynamodb_datastore.py index 71f81ac..c73071e 100644 --- a/src/eps_spine_shared/common/dynamodb_datastore.py +++ b/src/eps_spine_shared/common/dynamodb_datastore.py @@ -25,7 +25,7 @@ ) from eps_spine_shared.common.dynamodb_index import EpsDynamoDbIndex, PrescriptionStatus from eps_spine_shared.logger import EpsLogger -from eps_spine_shared.nhsfundamentals.timeutilities import ( +from eps_spine_shared.nhsfundamentals.time_utilities import ( TimeFormats, convert_spine_date, time_now_as_string, diff --git a/src/eps_spine_shared/common/dynamodb_index.py b/src/eps_spine_shared/common/dynamodb_index.py index 420dc1a..b2af84d 100644 --- a/src/eps_spine_shared/common/dynamodb_index.py +++ b/src/eps_spine_shared/common/dynamodb_index.py @@ -17,7 +17,7 @@ ) from eps_spine_shared.common.prescription.record import PrescriptionStatus from eps_spine_shared.logger import EpsLogger -from eps_spine_shared.nhsfundamentals.timeutilities import TimeFormats +from eps_spine_shared.nhsfundamentals.time_utilities import TimeFormats class EpsDynamoDbIndex: diff --git a/src/eps_spine_shared/common/indexes.py b/src/eps_spine_shared/common/indexes.py index ce39a81..fa742b2 100644 --- a/src/eps_spine_shared/common/indexes.py +++ b/src/eps_spine_shared/common/indexes.py @@ -1,6 +1,6 @@ from eps_spine_shared.errors import EpsSystemError from eps_spine_shared.logger import EpsLogger -from eps_spine_shared.nhsfundamentals.timeutilities import time_now_as_string +from eps_spine_shared.nhsfundamentals.time_utilities import time_now_as_string INDEX_NHSNUMBER_DATE = "nhsNumberDate_bin" INDEX_NHSNUMBER_PRDATE = "nhsNumberPrescriberDate_bin" diff --git a/src/eps_spine_shared/common/prescription/issue.py b/src/eps_spine_shared/common/prescription/issue.py index bd5591f..51cda8e 100644 --- a/src/eps_spine_shared/common/prescription/issue.py +++ b/src/eps_spine_shared/common/prescription/issue.py @@ -4,7 +4,7 @@ from eps_spine_shared.common.prescription.claim import PrescriptionClaim from eps_spine_shared.common.prescription.line_item import PrescriptionLineItem from eps_spine_shared.common.prescription.statuses import PrescriptionStatus -from eps_spine_shared.nhsfundamentals.timeutilities import TimeFormats +from eps_spine_shared.nhsfundamentals.time_utilities import TimeFormats class PrescriptionIssue(object): @@ -73,7 +73,6 @@ def expire(self, expired_at_time, parent_prescription): :type expired_at_time: datetime.datetime :type parent_prescription: PrescriptionRecord """ - currentStatus = self.status # update the issue status, if appropriate diff --git a/src/eps_spine_shared/common/prescription/next_activity_generator.py b/src/eps_spine_shared/common/prescription/next_activity_generator.py index 5c1aa8b..c7df0d6 100644 --- a/src/eps_spine_shared/common/prescription/next_activity_generator.py +++ b/src/eps_spine_shared/common/prescription/next_activity_generator.py @@ -3,7 +3,7 @@ from eps_spine_shared.common.prescription import fields from eps_spine_shared.common.prescription.statuses import PrescriptionStatus from eps_spine_shared.logger import EpsLogger -from eps_spine_shared.nhsfundamentals.timeutilities import TimeFormats +from eps_spine_shared.nhsfundamentals.time_utilities import TimeFormats class NextActivityGenerator(object): diff --git a/src/eps_spine_shared/common/prescription/record.py b/src/eps_spine_shared/common/prescription/record.py index 2b086c2..2123a84 100644 --- a/src/eps_spine_shared/common/prescription/record.py +++ b/src/eps_spine_shared/common/prescription/record.py @@ -15,8 +15,8 @@ EpsSystemError, ) from eps_spine_shared.logger import EpsLogger -from eps_spine_shared.nhsfundamentals.timeutilities import TimeFormats -from eps_spine_shared.spinecore.baseutilities import handle_encoding_oddities, quoted +from eps_spine_shared.nhsfundamentals.time_utilities import TimeFormats +from eps_spine_shared.spinecore.base_utilities import handle_encoding_oddities, quoted from eps_spine_shared.spinecore.changelog import PrescriptionsChangeLogProcessor @@ -58,7 +58,6 @@ def create_initial_record(self, context, prescription=True): of a cancellation (prior to a prescription) in which case this should be set to False. """ - self.name_map_on_create(context) self.prescription_record = {} @@ -103,7 +102,6 @@ def return_changed_issue_list( Accept an initial changed_issues_list as this may need to include other issues, e.g. in the pending cancellation case, an issue can be changed by adding a pending cancellation, even though the statuses don't change. """ - if not changed_issues_list: changed_issues_list = [] @@ -257,7 +255,6 @@ def name_map_on_create(self, context): named differently at the point of extract from the message such as with agentOrganization) """ - context.prescribingOrganization = context.agentOrganization if hasattr(context, fields.FIELD_PRESCRIPTION_REPEAT_HIGH): context.maxRepeats = context.prescriptionRepeatHigh @@ -336,7 +333,6 @@ def create_line_items(self, context): """ Create individual line items """ - complete_line_items = [] for line_item in context.lineItems: @@ -640,17 +636,17 @@ def add_release_and_status(self, index_prefix, is_string=True): Returns a list containing the index prefix concatenated with all applicable release versions and Prescription Statuses """ - _release_version = self._release_version - _status_list = self.return_prescription_status_set() + release_version = self._release_version + status_list = self.return_prescription_status_set() return_set = [] - for each_status in _status_list: + for each_status in status_list: if not is_string: for each_index in index_prefix: - _new_value = each_index + "|" + _release_version + "|" + each_status - return_set.append(_new_value) + new_value = each_index + "|" + release_version + "|" + each_status + return_set.append(new_value) else: - _new_value = index_prefix + "|" + _release_version + "|" + each_status - return_set.append(_new_value) + new_value = index_prefix + "|" + release_version + "|" + each_status + return_set.append(new_value) return return_set @@ -671,11 +667,11 @@ def return_presc_site_status_index(self): """ Return the prescribing organization and the prescription status """ - _presc_site = self.prescription_record[fields.FIELD_PRESCRIPTION][ + presc_site = self.prescription_record[fields.FIELD_PRESCRIPTION][ fields.FIELD_PRESCRIBING_ORG ] - _presc_status = self.return_prescription_status_set() - return [True, _presc_site, _presc_status] + presc_status = self.return_prescription_status_set() + return [True, presc_site, presc_status] def return_nom_pharm_status_index(self): """ @@ -700,12 +696,12 @@ def return_disp_site_or_nom_pharm(self, instance): Returns the Dispensing Site if available, otherwise, returns the Nominated Pharmacy or None if neither exist """ - _disp_site = instance.get(fields.FIELD_DISPENSE, {}).get( + disp_site = instance.get(fields.FIELD_DISPENSE, {}).get( fields.FIELD_DISPENSING_ORGANIZATION ) - if not _disp_site: - _disp_site = self.return_nom_pharm() - return _disp_site + if not disp_site: + disp_site = self.return_nom_pharm() + return disp_site def return_disp_site_status_index(self): """ @@ -715,11 +711,11 @@ def return_disp_site_status_index(self): dispensing_site_statuses = set() for instance_key in self.prescription_record[fields.FIELD_INSTANCES]: instance = self._get_prescription_instance_data(instance_key) - _disp_site = self.return_disp_site_or_nom_pharm(instance) - if not _disp_site: + disp_site = self.return_disp_site_or_nom_pharm(instance) + if not disp_site: continue - _presc_status = instance[fields.FIELD_PRESCRIPTION_STATUS] - dispensing_site_statuses.add(_disp_site + "_" + _presc_status) + presc_status = instance[fields.FIELD_PRESCRIPTION_STATUS] + dispensing_site_statuses.add(disp_site + "_" + presc_status) return [True, dispensing_site_statuses] @@ -734,10 +730,10 @@ def return_nhs_number_prescriber_dispenser_date_index(self): nhs_number_presc_disp_dates = set() for instance_key in self.prescription_record[fields.FIELD_INSTANCES]: instance = self._get_prescription_instance_data(instance_key) - _disp_site = self.return_disp_site_or_nom_pharm(instance) - if not _disp_site: + disp_site = self.return_disp_site_or_nom_pharm(instance) + if not disp_site: continue - nhs_number_presc_disp_dates.add(index_start + _disp_site + "|" + prescription_time) + nhs_number_presc_disp_dates.add(index_start + disp_site + "|" + prescription_time) return [True, nhs_number_presc_disp_dates] @@ -784,10 +780,10 @@ def return_nhs_number_dispenser_date_index(self): nhs_number_disp_dates = set() for instance_key in self.prescription_record[fields.FIELD_INSTANCES]: instance = self._get_prescription_instance_data(instance_key) - _disp_site = self.return_disp_site_or_nom_pharm(instance) - if not _disp_site: + disp_site = self.return_disp_site_or_nom_pharm(instance) + if not disp_site: continue - nhs_number_disp_dates.add(index_start + _disp_site + "|" + prescription_time) + nhs_number_disp_dates.add(index_start + disp_site + "|" + prescription_time) return [True, nhs_number_disp_dates] @@ -796,9 +792,9 @@ def return_nominated_performer(self): Return the nominated performer (called when determining routing key extension) """ nom_performer = None - _nomination = self.prescription_record.get(fields.FIELD_NOMINATION) - if _nomination: - nom_performer = _nomination.get(fields.FIELD_NOMINATED_PERFORMER) + nomination = self.prescription_record.get(fields.FIELD_NOMINATION) + if nomination: + nom_performer = nomination.get(fields.FIELD_NOMINATED_PERFORMER) return nom_performer def return_nominated_performer_type(self): @@ -806,9 +802,9 @@ def return_nominated_performer_type(self): Return the nominated performer type """ nom_performer_type = None - _nomination = self.prescription_record.get(fields.FIELD_NOMINATION) - if _nomination: - nom_performer_type = _nomination.get(fields.FIELD_NOMINATED_PERFORMER_TYPE) + nomination = self.prescription_record.get(fields.FIELD_NOMINATION) + if nomination: + nom_performer_type = nomination.get(fields.FIELD_NOMINATED_PERFORMER_TYPE) return nom_performer_type def return_prescription_status_set(self): @@ -844,38 +840,36 @@ def return_pending_cancellations_flag(self): """ Return the pending cancellations flag """ - _prescription = self.prescription_record[fields.FIELD_PRESCRIPTION] - _max_repeats = _prescription.get(fields.FIELD_MAX_REPEATS) + prescription = self.prescription_record[fields.FIELD_PRESCRIPTION] + max_repeats = prescription.get(fields.FIELD_MAX_REPEATS) - if not _max_repeats: - _max_repeats = 1 + if not max_repeats: + max_repeats = 1 - for prescription_issue in range(1, int(_max_repeats) + 1): - _prescription_issue = self.prescription_record[fields.FIELD_INSTANCES].get( + for prescription_issue in range(1, int(max_repeats) + 1): + prescription_issue = self.prescription_record[fields.FIELD_INSTANCES].get( str(prescription_issue) ) # handle missing issues - if not _prescription_issue: + if not prescription_issue: continue issue_specific_cancellations = {} - _applied_cancellations_for_issue = _prescription_issue.get( - fields.FIELD_CANCELLATIONS, [] - ) - _cancellation_status_string_prefix = "" + applied_cancellations_for_issue = prescription_issue.get(fields.FIELD_CANCELLATIONS, []) + cancellation_status_string_prefix = "" self._create_cancellation_summary_dict( - _applied_cancellations_for_issue, + applied_cancellations_for_issue, issue_specific_cancellations, - _cancellation_status_string_prefix, + cancellation_status_string_prefix, ) - if str(_prescription_issue[fields.FIELD_INSTANCE_NUMBER]) == str( - _prescription[fields.FIELD_CURRENT_INSTANCE] + if str(prescription_issue[fields.FIELD_INSTANCE_NUMBER]) == str( + prescription[fields.FIELD_CURRENT_INSTANCE] ): - _pending_cancellations = _prescription[fields.FIELD_PENDING_CANCELLATIONS] - _cancellation_status_string_prefix = "Pending: " + pending_cancellations = prescription[fields.FIELD_PENDING_CANCELLATIONS] + cancellation_status_string_prefix = "Pending: " self._create_cancellation_summary_dict( - _pending_cancellations, + pending_cancellations, issue_specific_cancellations, - _cancellation_status_string_prefix, + cancellation_status_string_prefix, ) for _, val in issue_specific_cancellations.items(): if val.get(fields.FIELD_REASONS, "")[:7] == "Pending": @@ -892,43 +886,40 @@ def _create_cancellation_summary_dict( cancellationStatus is used to seed the reasons in the pending scenario. """ - if not recorded_cancellations: return - for _cancellation in recorded_cancellations: - _subsequent_reason = False - _cancellation_reasons = str(cancellation_status) + for cancellation in recorded_cancellations: + subsequent_reason = False + cancellation_reasons = str(cancellation_status) - _cancellation_id = _cancellation.get(fields.FIELD_CANCELLATION_ID, []) - _scn = PrescriptionsChangeLogProcessor.get_scn( - self.prescription_record["changeLog"].get(_cancellation_id, {}) + cancellation_id = cancellation.get(fields.FIELD_CANCELLATION_ID, []) + scn = PrescriptionsChangeLogProcessor.get_scn( + self.prescription_record["changeLog"].get(cancellation_id, {}) ) - for _cancellation_reason in _cancellation.get(fields.FIELD_REASONS, []): - _cancellation_text = _cancellation_reason.split(":")[1].strip() - if _subsequent_reason: - _cancellation_reasons += "; " - _subsequent_reason = True - _cancellation_reasons += str(handle_encoding_oddities(_cancellation_text)) - - if ( - _cancellation.get(fields.FIELD_CANCELLATION_TARGET) == "Prescription" - ): # noqa: SIM108 - _cancellation_target = fields.FIELD_PRESCRIPTION + for cancellation_reason in cancellation.get(fields.FIELD_REASONS, []): + cancellation_text = cancellation_reason.split(":")[1].strip() + if subsequent_reason: + cancellation_reasons += "; " + subsequent_reason = True + cancellation_reasons += str(handle_encoding_oddities(cancellation_text)) + + if cancellation.get(fields.FIELD_CANCELLATION_TARGET) == "Prescription": # noqa: SIM108 + cancellation_target = fields.FIELD_PRESCRIPTION else: - _cancellation_target = _cancellation.get(fields.FIELD_CANCEL_LINE_ITEM_REF) + cancellation_target = cancellation.get(fields.FIELD_CANCEL_LINE_ITEM_REF) if ( - issue_cancellation_dict.get(_cancellation_target, {}).get(fields.FIELD_ID) - == _cancellation_id + issue_cancellation_dict.get(cancellation_target, {}).get(fields.FIELD_ID) + == cancellation_id ): # Cancellation has already been added and this is pending as multiple cancellations are not possible return - issue_cancellation_dict[_cancellation_target] = { - fields.FIELD_SCN: _scn, - fields.FIELD_REASONS: _cancellation_reasons, - fields.FIELD_ID: _cancellation_id, + issue_cancellation_dict[cancellation_target] = { + fields.FIELD_SCN: scn, + fields.FIELD_REASONS: cancellation_reasons, + fields.FIELD_ID: cancellation_id, } def return_current_instance(self): @@ -1132,7 +1123,6 @@ def check_record_consistency(self, context): Check a nominatedPerformer is set for repeat prescriptions (although this may not be required as a check due to DPR rules) """ - test_failures = [] instance_dict = self._get_prescription_instance_data(context.currentInstance) @@ -1307,7 +1297,6 @@ def _include_next_activity_for_instance( :type max_repeats: int :rtype: bool """ - issue_is_current = issue_number == current_issue_number if not issue_is_final: issue_is_final = issue_number == max_repeats @@ -1698,7 +1687,6 @@ def update_for_rebuild( Complete the actions required to update the prescription instance with the changes made in the interaction worker """ - instance = self._get_prescription_instance_data(context.targetInstance) instance[fields.FIELD_DISPENSE][fields.FIELD_LAST_DISPENSE_DATE] = dispense_dict[ fields.FIELD_DISPENSE_DATE @@ -1762,7 +1750,6 @@ def update_for_return(self, _, retain_nomination=False): The status then needs to be changed for the prescription and the line items """ - self.clear_dispensing_organisation(self._current_instance_data) self.update_instance_status(self._current_instance_data, PrescriptionStatus.TO_BE_DISPENSED) @@ -1797,7 +1784,6 @@ def check_action_applicability(self, target_instance, action, context): the target instance then the update has come from a test or admin system that needs to take action on a specific instance, so skip the applicability test. """ - if target_instance != fields.BATCH_STATUS_AVAILABLE: self.set_instance_to_action_update(target_instance, context, action) else: @@ -2306,19 +2292,20 @@ def instances_to_update(self, target_instance): self.log_object.write_log( "EPS0297a", None, - dict( - { - "internalID": self.internal_id, - "startInstance": start_instance, - "endInstance": end_instance, - } - ), + { + "internalID": self.internal_id, + "startInstance": start_instance, + "endInstance": end_instance, + }, ) else: self.log_object.write_log( "EPS0297b", None, - dict({"internalID": self.internal_id, "startInstance": start_instance}), + { + "internalID": self.internal_id, + "startInstance": start_instance, + }, ) return [instance_range, start_instance, end_instance] @@ -2327,7 +2314,6 @@ def make_withdrawal_updates(self, context): """ Apply instance specific updates into record """ - target_instance = context.targetInstance prescription = self.prescription_record instance = prescription[fields.FIELD_INSTANCES][target_instance] @@ -2342,7 +2328,6 @@ def _make_admin_instance_updates(self, context, instance_number): """ Apply instance specific updates into record """ - current_instance = str(instance_number) context.updateInstance = instance_number prescription = self.prescription_record @@ -2632,9 +2617,7 @@ def release_next_instance( self.log_object.write_log( "EPS0676", None, - dict( - {"internalID": self.internal_id, "prescriptionID": context.prescriptionID} - ), + {"internalID": self.internal_id, "prescriptionID": context.prescriptionID}, ) nominated_download_date = self._calculate_nominated_download_date( prescribe_date[:8], days_supply, nom_down_lead_days, next_issue_number_str @@ -2642,19 +2625,17 @@ def release_next_instance( self.log_object.write_log( "EPS0675", None, - dict( - { - "internalID": self.internal_id, - "prescriptionID": context.prescriptionID, - "nominatedDownloadDate": nominated_download_date.strftime( - TimeFormats.STANDARD_DATE_FORMAT - ), - "prescribeDate": prescribe_date, - "daysSupply": str(days_supply), - "leadDays": str(nom_down_lead_days), - "issueNumber": next_issue_number_str, - } - ), + { + "internalID": self.internal_id, + "prescriptionID": context.prescriptionID, + "nominatedDownloadDate": nominated_download_date.strftime( + TimeFormats.STANDARD_DATE_FORMAT + ), + "prescribeDate": prescribe_date, + "daysSupply": str(days_supply), + "leadDays": str(nom_down_lead_days), + "issueNumber": next_issue_number_str, + }, ) else: nominated_download_date = self._calculate_nominated_download_date_old( @@ -2715,7 +2696,6 @@ def clear_dispense_notifications_from_history(self, target_instance): """ Clear all but the release from the dispense history """ - instance = self._get_prescription_instance_data(target_instance) new_dispense_history = {} if fields.FIELD_RELEASE in instance[fields.FIELD_DISPENSE_HISTORY]: @@ -2779,7 +2759,6 @@ def create_release_history_entry(self, release_time, dispensing_org): Use the release date as the last dispense date to support next activity calculation if the dispense history is withdrawn. """ - instance = self._current_instance_data instance[fields.FIELD_DISPENSE_HISTORY][fields.FIELD_RELEASE] = {} @@ -3084,7 +3063,6 @@ def reset_current_instance(self): :returns: a list containing the old and new "current instance" number as strings :rtype: [str, str] """ - # see if we can find an issue from the current one upwards in an active or future state new_current_issue_number = None acceptable_states = PrescriptionStatus.ACTIVE_STATES + PrescriptionStatus.FUTURE_STATES @@ -3360,14 +3338,12 @@ def check_pending_cancellation_unique_w_disp(self, cancellation_obj): self.log_object.write_log( "EPS0264a", None, - dict( - { - "internalID": self.internal_id, - "pendingOrg": pending_org, - "cancellationTarget": cancellation_target, - "cancellationOrg": cancellation_org, - } - ), + { + "internalID": self.internal_id, + "pendingOrg": pending_org, + "cancellationTarget": cancellation_target, + "cancellationOrg": cancellation_org, + }, ) return [False, org_match] @@ -3390,7 +3366,6 @@ def check_pending_cancellation_unique(self, cancellation_obj): that in this case a whole prescription cancellation takes precedence over individual line item cancellations. """ - if not self._pending_cancellations: return [True, None] @@ -3418,14 +3393,12 @@ def check_pending_cancellation_unique(self, cancellation_obj): self.log_object.write_log( "EPS0264a", None, - dict( - { - "internalID": self.internal_id, - "pendingOrg": pending_org, - "cancellationTarget": cancellation_target, - "cancellationOrg": cancellation_org, - } - ), + { + "internalID": self.internal_id, + "pendingOrg": pending_org, + "cancellationTarget": cancellation_target, + "cancellationOrg": cancellation_org, + }, ) return [False, org_match] @@ -3456,7 +3429,6 @@ def set_pending_cancellation(self, cancellation_obj, prescription_present): Set the default Prescription Pending Cancellation status code and then Append a cancellation object to the pendingCancellations """ - if not prescription_present: instance = self._get_prescription_instance_data("1") self.update_instance_status(instance, PrescriptionStatus.PENDING_CANCELLATION) @@ -3479,13 +3451,11 @@ def set_pending_cancellation(self, cancellation_obj, prescription_present): self.log_object.write_log( "EPS0340", None, - dict( - { - "internalID": self.internal_id, - "cancellationDate": cancellation_date, - "prescriptionID": self.return_prescription_id(), - } - ), + { + "internalID": self.internal_id, + "cancellationDate": cancellation_date, + "prescriptionID": self.return_prescription_id(), + }, ) else: pending_cs.append(cancellation_obj) diff --git a/src/eps_spine_shared/common/prescription/repeat_dispense.py b/src/eps_spine_shared/common/prescription/repeat_dispense.py index e73e48b..befdef1 100644 --- a/src/eps_spine_shared/common/prescription/repeat_dispense.py +++ b/src/eps_spine_shared/common/prescription/repeat_dispense.py @@ -24,7 +24,6 @@ def create_instances(self, context, line_items): Expire any lineItems that have a lower max_repeats number than the instance number """ - instance_snippets = {} range_max = int(context.maxRepeats) + 1 diff --git a/src/eps_spine_shared/common/prescription/single_prescribe.py b/src/eps_spine_shared/common/prescription/single_prescribe.py index 2b94b96..a80cd63 100644 --- a/src/eps_spine_shared/common/prescription/single_prescribe.py +++ b/src/eps_spine_shared/common/prescription/single_prescribe.py @@ -1,6 +1,6 @@ from eps_spine_shared.common.prescription import fields from eps_spine_shared.common.prescription.record import PrescriptionRecord -from eps_spine_shared.spinecore.baseutilities import quoted +from eps_spine_shared.spinecore.base_utilities import quoted class SinglePrescribeRecord(PrescriptionRecord): diff --git a/src/eps_spine_shared/errors.py b/src/eps_spine_shared/errors.py index c650be1..a41da60 100644 --- a/src/eps_spine_shared/errors.py +++ b/src/eps_spine_shared/errors.py @@ -2,6 +2,8 @@ from botocore.exceptions import NoCredentialsError +CODESYSTEM_1634 = "2.16.840.1.113883.2.1.3.2.4.16.34" + # Try to import spine error classes. If successful, we are on spine and should use wrapper classes. on_spine = False try: @@ -14,6 +16,9 @@ SpineBusinessError, SpineSystemError, ) + from spinecore.prescriptions.common.errors.errorbase1634 import ( # pyright: ignore[reportMissingImports] + ErrorBase1634, + ) # from spinecore.prescriptions.common.errors.errorbase1634 \ # import ErrorBase1634 # pyright: ignore[reportMissingImports] @@ -23,6 +28,9 @@ from spinecore.prescriptions.common.errors.errorbase1722 import ( # pyright: ignore[reportMissingImports] ErrorBase1722, ) + from spinecore.prescriptions.common.local_validator import ( # pyright: ignore[reportMissingImports] + ValidationError, + ) on_spine = True except ImportError: @@ -47,23 +55,16 @@ class EpsSystemError: def __init__(self, *args): raise SpineSystemError(*args) - class EpsBusinessError: - """ - Wrapper for SpineBusinessError - """ - - def __init__(self, *args): - raise SpineBusinessError(*args) - class EpsErrorBase: """ Wrapper for ErrorBases """ - MISSING_ISSUE = ErrorBase1722.PRESCRIPTION_NOT_FOUND - ITEM_NOT_FOUND = ErrorBase1722.ITEM_NOT_FOUND + EXISTS_WITH_NEXT_ACTIVITY_PURGE = ErrorBase1722.EXISTS_WITH_NEXT_ACTIVITY_PURGE INVALID_LINE_STATE_TRANSITION = ErrorBase1722.INVALID_LINE_STATE_TRANSITION + ITEM_NOT_FOUND = ErrorBase1722.ITEM_NOT_FOUND MAX_REPEAT_MISMATCH = ErrorBase1722.MAX_REPEAT_MISMATCH + MISSING_ISSUE = ErrorBase1722.PRESCRIPTION_NOT_FOUND NOT_CANCELLED_EXPIRED = ErrorBase1719.NOT_CANCELLED_EXPIRED NOT_CANCELLED_CANCELLED = ErrorBase1719.NOT_CANCELLED_CANCELLED NOT_CANCELLED_NOT_DISPENSED = ErrorBase1719.NOT_CANCELLED_NOT_DISPENSED @@ -71,6 +72,23 @@ class EpsErrorBase: NOT_CANCELLED_WITH_DISPENSER = ErrorBase1719.NOT_CANCELLED_WITH_DISPENSER NOT_CANCELLED_WITH_DISPENSER_ACTIVE = ErrorBase1719.NOT_CANCELLED_WITH_DISPENSER_ACTIVE PRESCRIPTION_NOT_FOUND = ErrorBase1719.PRESCRIPTION_NOT_FOUND + UNABLE_TO_PROCESS = ErrorBase1634.UNABLE_TO_PROCESS + + class EpsBusinessError: + """ + Wrapper for SpineBusinessError + """ + + def __init__(self, *args): + raise SpineBusinessError(*args) + + class EpsValidationError: + """ + Wrapper for ValidationError + """ + + def __init__(self, *args): + raise ValidationError(*args) else: @@ -101,15 +119,34 @@ def __init__(self, error_topic, *args): # noqa: B042 super(EpsSystemError, self).__init__(*args) self.error_topic = error_topic + class EpsErrorBase(Enum): + """ + To be used as error_code for EpsBusinessError. + """ + + EXISTS_WITH_NEXT_ACTIVITY_PURGE = 0 + INVALID_LINE_STATE_TRANSITION = 1 + ITEM_NOT_FOUND = 2 + MAX_REPEAT_MISMATCH = 3 + MISSING_ISSUE = 4 + NOT_CANCELLED_EXPIRED = 5 + NOT_CANCELLED_CANCELLED = 6 + NOT_CANCELLED_NOT_DISPENSED = 7 + NOT_CANCELLED_DISPENSED = 8 + NOT_CANCELLED_WITH_DISPENSER = 9 + NOT_CANCELLED_WITH_DISPENSER_ACTIVE = 10 + PRESCRIPTION_NOT_FOUND = 11 + UNABLE_TO_PROCESS = 12 + class EpsBusinessError(Exception): """ Exception to be raised by a message worker if an expected error condition is hit, one that is expected to cause a HL7 error response with a set errorCode. """ - def __init__(self, error_code, supp_info=None, message_id=None): # noqa: B042 + def __init__(self, error_code: EpsErrorBase, supp_info=None, message_id=None): # noqa: B042 super(EpsBusinessError, self).__init__() - self.error_code = error_code + self.error_code: EpsErrorBase = error_code self.supplementary_information = supp_info self.message_id = message_id @@ -118,19 +155,15 @@ def __str__(self): return "{} {}".format(self.error_code, self.supplementary_information) return str(self.error_code) - class EpsErrorBase(Enum): + class EpsValidationError(Exception): """ - To be used in Spine application code to remap to ErrorBases. + Exception to be raised by validation functions. + Must be passed supplementary information to be appended to error response text. """ - INVALID_LINE_STATE_TRANSITION = 1 - ITEM_NOT_FOUND = 2 - MAX_REPEAT_MISMATCH = 3 - NOT_CANCELLED_EXPIRED = 4 - NOT_CANCELLED_CANCELLED = 5 - NOT_CANCELLED_NOT_DISPENSED = 6 - NOT_CANCELLED_DISPENSED = 7 - NOT_CANCELLED_WITH_DISPENSER = 8 - NOT_CANCELLED_WITH_DISPENSER_ACTIVE = 9 - PRESCRIPTION_NOT_FOUND = 10 - MISSING_ISSUE = 11 + def __init__(self, supplementary_info): + """ + Add supplementary information + """ + super(EpsValidationError, self).__init__(supplementary_info) + self.supp_info = supplementary_info diff --git a/src/eps_spine_shared/interactions/common.py b/src/eps_spine_shared/interactions/common.py new file mode 100644 index 0000000..0108e6f --- /dev/null +++ b/src/eps_spine_shared/interactions/common.py @@ -0,0 +1,458 @@ +import base64 +import datetime +import sys +import zlib + +from dateutil import relativedelta + +from eps_spine_shared.common import indexes +from eps_spine_shared.common.dynamodb_common import prescription_id_without_check_digit +from eps_spine_shared.common.dynamodb_datastore import EpsDynamoDbDataStore +from eps_spine_shared.common.prescription import fields +from eps_spine_shared.common.prescription.repeat_dispense import RepeatDispenseRecord +from eps_spine_shared.common.prescription.repeat_prescribe import RepeatPrescribeRecord +from eps_spine_shared.common.prescription.single_prescribe import SinglePrescribeRecord +from eps_spine_shared.errors import EpsSystemError +from eps_spine_shared.interactions.updates import apply_blind_update, apply_smart_update +from eps_spine_shared.logger import EpsLogger +from eps_spine_shared.nhsfundamentals.time_utilities import TimeFormats +from eps_spine_shared.spinecore.base_utilities import handle_encoding_oddities +from eps_spine_shared.spinecore.changelog import PrescriptionsChangeLogProcessor + +CANCEL_INTERACTION = "PORX_IN050102UK32" +EXPECTED_DELETE_WAIT_TIME_MONTHS = 18 +EXPECTED_NOMINATED_RELEASE_DELETE_WAIT_TIME_DAYS = 36 +SERVICE = "urn:nhs:names:services:mm" +TEST_PRESCRIBING_SITES = ["Z99901", "Z99902"] + +PRESCRIPTION_EXPIRY_PERIOD_MONTHS = 6 +REPEAT_DISP_EXPIRY_PERIOD_MONTHS = 12 +DATA_CLEANSE_PERIOD_MONTHS = 6 +WD_ACTIVE_EXPIRY_PERIOD_DAYS = 180 +EXPIRED_DELETE_PERIOD = 90 +CANCELLED_DELETE_PERIOD = 180 +CLAIMED_DELETE_PERIOD = 36 +NOT_DISPENSED_DELETE_PERIOD = 30 +NOMINATED_DOWNLOAD_LEAD_DAYS = 7 +NOTIFICATION_DELAY_PERIOD = 180 +PURGED_DELETE_PERIOD = 365 + + +def check_for_replay( + eps_record_id, eps_record_retrieved, message_id, context, internal_id, log_object: EpsLogger +): + """ + Check a retrieved record for the existence of the message GUID within the change log + """ + try: + change_log = eps_record_retrieved["changeLog"] + except Exception as e: # noqa: BLE001 + log_object.write_log( + "EPS0004", + sys.exc_info(), + {"internalID": internal_id, "epsRecordID": eps_record_id}, + ) + raise EpsSystemError("systemFailure") from e + + if message_id in change_log: + log_object.write_log( + "EPS0005", + None, + { + "internalID": internal_id, + "epsRecordID": eps_record_id, + "changeLog": str(change_log), + }, + ) + context.replayedChangeLog = change_log[message_id] + return True + + return False + + +def build_working_record(context, internal_id, log_object: EpsLogger): + """ + An epsRecord object needs to be created from the record extracted from the + store. The record-type should have been extracted - and this will be used to + determine which class of object to create. + + Note that Pending Cancellation placeholders will not have a recordType, so + default this to 'Acute' to allow processing to continue. + """ + record_type = ( + "Acute" + if "recordType" not in context.recordToProcess + else context.recordToProcess["recordType"] + ) + if record_type == "Acute": + context.epsRecord = SinglePrescribeRecord(log_object, internal_id) + elif record_type == "RepeatPrescribe": + context.epsRecord = RepeatPrescribeRecord(log_object, internal_id) + elif record_type == "RepeatDispense": + context.epsRecord = RepeatDispenseRecord(log_object, internal_id) + else: + log_object.write_log( + "EPS0133", None, {"internalID": internal_id, "recordType": str(record_type)} + ) + raise EpsSystemError("developmentFailure") + + context.epsRecord.create_record_from_store(context.recordToProcess["value"]) + + +def check_for_pending_cancellations(context): + """ + Check for pending cancellations on the record, and bind them to context + if they exist + """ + pending_cancellations = context.epsRecord.return_pending_cancellations() + if pending_cancellations: + context.cancellationObjects = pending_cancellations + + +def prepare_document_for_store( + context, doc_type, doc_ref_title, services_dict, deep_copy, internal_id, log_object: EpsLogger +): + """ + For inbound messages to be stored in the datastore. + The key for the object should be the internalID of the message. + """ + if context.replayDetected: + context.documentsToStore = None + return + + if ( + hasattr(context, "prescriptionID") and context.prescriptionID + ): # noqa: SIM108 - More readable as is + presc_id = context.prescriptionID + else: + presc_id = "NominatedReleaseRequest_" + internal_id + + document_ref = internal_id + + setattr(context, doc_ref_title, document_ref) + + document_to_store = {} + document_to_store["key"] = document_ref + document_to_store["value"] = extract_body_to_store( + presc_id, doc_type, context, services_dict, deep_copy, internal_id, log_object + ) + document_to_store["index"] = create_index_for_document(context, doc_ref_title, presc_id) + document_to_store["vectorClock"] = None + context.documentsToStore.append(document_to_store) + context.documentReferences.append(document_ref) + + log_object.write_log( + "EPS0125", + None, + {"internalID": internal_id, "type": doc_type, "key": document_ref, "vectorClock": "None"}, + ) + + +def extract_body_to_store( + prescription_id, + doc_type, + context, + services_dict, + deep_copy, + internal_id, + log_object: EpsLogger, + base_document=None, +): + """ + Extract the inbound message body and prepare as a document for the epsDocument + store + """ + try: + if base_document is None: + base_document = context.xmlBody + + deep_copy_transform = services_dict["Style Sheets"][deep_copy] + compressed_document = zlib.compress(str(deep_copy_transform(base_document))) + encoded_document = base64.b64encode(compressed_document) + value = {} + value["content"] = encoded_document + value["content type"] = "xml" + value["id"] = prescription_id + value["type"] = doc_type + except Exception as e: # noqa: BLE001 + log_object.write_log( + "EPS0014b", sys.exc_info(), {"internalID": internal_id, "type": doc_type} + ) + raise EpsSystemError(EpsSystemError.MESSAGE_FAILURE) from e + return value + + +def create_index_for_document(context, doc_ref_title, prescription_id): + """ + Index required is prescriptionID should there be a need to search for document by prescription ID + Other index is storeTimeByDocRefTitle - this allows for documents of a certain + type to be queried by the range of the document age (e.g. searching for all + Claim Notices which have been present for more than 48 hours) + """ + store_time = context.handleTime.strftime(TimeFormats.STANDARD_DATE_TIME_FORMAT) + + default_delta = relativedelta(months=+EXPECTED_DELETE_WAIT_TIME_MONTHS) + nominated_release_delta = relativedelta(days=+EXPECTED_NOMINATED_RELEASE_DELETE_WAIT_TIME_DAYS) + delete_date_obj_delta = ( + nominated_release_delta + if doc_ref_title == "NominatedReleaseRequestMsgRef" + else default_delta + ) + + delete_date_obj = context.handleTime + delete_date_obj_delta + delete_date = delete_date_obj.strftime(TimeFormats.STANDARD_DATE_FORMAT) + + index_dict = {} + index_dict[indexes.INDEX_PRESCRIPTION_ID] = [prescription_id] + index_dict[indexes.INDEX_STORE_TIME_DOC_REF_TITLE] = [doc_ref_title + "_" + store_time] + index_dict[indexes.INDEX_DELETE_DATE] = [delete_date] + + return index_dict + + +def log_pending_cancellation_event(context, start_issue_number, internal_id, log_object: EpsLogger): + """ + Generate a pending cancellation eventLog entry + """ + if not hasattr(context, "responseParameters"): + context.responseParameters = {} + context.responseParameters["cancellationResponseText"] = "Subsequent cancellation" + context.responseParameters["timeStampSent"] = datetime.datetime.now().strftime( + TimeFormats.STANDARD_DATE_TIME_FORMAT + ) + context.responseParameters["messageID"] = context.messageID + + context.responseDetails = {} + context.responseDetails[PrescriptionsChangeLogProcessor.RSP_PARAMS] = context.responseParameters + error_response_stylesheet = "generateHL7MCCIDetectedIssue.xsl" + cancellation_body_xslt = "cancellationRequest_to_cancellationResponse.xsl" + response_xslt = [error_response_stylesheet, cancellation_body_xslt] + context.responseDetails[PrescriptionsChangeLogProcessor.XSLT] = response_xslt + context.epsRecord.increment_scn() + create_event_log(context, internal_id, log_object, start_issue_number) + context.epsRecord.add_event_to_change_log(internal_id, context.eventLog) + + +def create_event_log(context, internal_id, log_object: EpsLogger, instance_id=None): + """ + Create the change log for this event. Will be placed on change log in record + under a key of the messageID + """ + if context.replayDetected: + return + + if not instance_id: + if context.epsRecord: + instance_id = context.epsRecord.return_current_instance() + else: + log_object.write_log("EPS0673", None, {"internalID": internal_id}) + instance_id = "NotAvailable" + context.instanceID = instance_id + + if context.epsRecord: + event_log = PrescriptionsChangeLogProcessor.log_for_domain_update(context, internal_id) + context.eventLog = event_log + + +def apply_all_cancellations( + context, + internal_id, + log_object: EpsLogger, + was_pending=False, + start_issue_number=None, + send_subsequent_cancellation=True, +): + """ + Apply all the cancellations on the context (these should normally be fetched from + the record) + """ + for cancellation_obj in context.cancellationObjects: + [cancel_id, issues_updated] = context.epsRecord.apply_cancellation( + cancellation_obj, start_issue_number + ) + log_object.write_log( + "EPS0266", + None, + { + "internalID": internal_id, + "prescriptionID": context.prescriptionID, + "issuesUpdated": issues_updated, + "cancellationID": cancel_id, + }, + ) + + if not is_death(cancellation_obj, internal_id, log_object): + if was_pending and send_subsequent_cancellation: + context.cancellationObjects.append(cancellation_obj) + + +def is_death(cancellation_obj, internal_id, log_object: EpsLogger): + """ + Returns True if this is a Death Notification + """ + reasons = cancellation_obj.get(fields.FIELD_REASONS) + + if not reasons: + return False + + for reason in reasons: + if str(handle_encoding_oddities(reason)).lower().find("notification of death") != -1: + log_object.write_log( + "EPS0652", None, {"internalID": internal_id, "reason": str(reason)} + ) + return True + + return False + + +def prepare_record_for_store( + context, internal_id, log_object: EpsLogger, fetched_record=False, key=None +): + """ + Prepare the record to be stored: + 1 - Check there is a need to store (not replay) + 2 - Set the key + 3 - Add change log to record + 4 - Set the index (including calculation of nextActivity) + 5 - Set the value (from the epsRecord object) + + fetched_record indicates whether the recordToStore is based on one retrieved by + this interactionWorker process. If it is, there will be a vectorClock, which + is required in order for the updateApplier to use as an optimistic 'lock' + + key if passed will be used as the key to be stored (otherwise generate from + context.prescriptionID) + """ + if context.replayDetected: + context.recordToStore = None + return + + context.recordToStore = {} + + if not key: + presc_id = prescription_id_without_check_digit(context.prescriptionID) + context.recordToStore["key"] = presc_id + else: + context.recordToStore["key"] = key + + index_dict = create_record_index(context, internal_id, log_object) + context.recordToStore["index"] = index_dict + context.epsRecord.add_index_to_record(index_dict) + context.epsRecord.add_document_references(context.documentReferences) + + context.epsRecord.increment_scn() + context.epsRecord.add_event_to_change_log(context.messageID, context.eventLog) + + context.recordToStore["value"] = context.epsRecord.return_record_to_be_stored() + + if fetched_record: + context.recordToStore["vectorClock"] = context.recordToProcess["vectorClock"] + else: + context.recordToStore["vectorClock"] = None + + context.recordToStore["recordType"] = context.epsRecord.record_type + + log_object.write_log( + "EPS0125", + None, + { + "internalID": internal_id, + "type": "prescriptionRecord", + "key": context.recordToStore["key"], + "vectorClock": "None", + }, + ) + + +def create_record_index(context, internal_id, log_object: EpsLogger): + """ + Create the index values to be used when storing the epsRecord. + There may be separate index terms for each individual instance + (but only unique index terms for the prescription should be returned). + """ + index_maker = indexes.EpsIndexFactory( + log_object, internal_id, TEST_PRESCRIBING_SITES, get_nad_references() + ) + return index_maker.build_indexes(context) + + +def get_nad_references(): + """ + Create a reference dictionary of information + for use during next activity date calculation + """ + return { + "prescriptionExpiryPeriod": relativedelta(months=+PRESCRIPTION_EXPIRY_PERIOD_MONTHS), + "repeatDispenseExpiryPeriod": relativedelta(months=+REPEAT_DISP_EXPIRY_PERIOD_MONTHS), + "dataCleansePeriod": relativedelta(months=+DATA_CLEANSE_PERIOD_MONTHS), + "withDispenserActiveExpiryPeriod": relativedelta(days=+WD_ACTIVE_EXPIRY_PERIOD_DAYS), + "expiredDeletePeriod": relativedelta(days=+EXPIRED_DELETE_PERIOD), + "cancelledDeletePeriod": relativedelta(days=+CANCELLED_DELETE_PERIOD), + "claimedDeletePeriod": relativedelta(days=+CLAIMED_DELETE_PERIOD), + "notDispensedDeletePeriod": relativedelta(days=+NOT_DISPENSED_DELETE_PERIOD), + "nominatedDownloadDateLeadTime": relativedelta(days=+NOMINATED_DOWNLOAD_LEAD_DAYS), + "notificationDelayPeriod": relativedelta(days=+NOTIFICATION_DELAY_PERIOD), + "purgedDeletePeriod": relativedelta(days=+PURGED_DELETE_PERIOD), + } + + +def apply_updates( + context, + failure_count, + internal_id, + log_object: EpsLogger, + datastore_object: EpsDynamoDbDataStore, +): + """ + Apply record and document updates directly + """ + log_object.write_log("EPS0900", None, {"internalID": internal_id}) + + add_documents_to_store(context, internal_id, log_object, datastore_object) + apply_record_change_to_store(context, failure_count, internal_id, log_object, datastore_object) + + +def add_documents_to_store( + context, internal_id, log_object: EpsLogger, datastore_object: EpsDynamoDbDataStore +): + """ + Add documents to the store from the context + """ + documents_to_store = context.documentsToStore + if not documents_to_store: + log_object.write_log("EPS0910", None, {"internalID": internal_id}) + return + + for document_to_store in documents_to_store: + apply_blind_update( + document_to_store, "epsDocument", internal_id, log_object, datastore_object + ) + + +def apply_record_change_to_store( + context, + failure_count, + internal_id, + log_object: EpsLogger, + datastore_object: EpsDynamoDbDataStore, +): + """ + Apply the record change to the store from the context + """ + record_to_store = context.recordToStore + + if not record_to_store: + log_object.write_log("EPS0920", None, {"internalID": internal_id}) + return + + if not record_to_store["vectorClock"]: + apply_blind_update(record_to_store, "epsRecord", internal_id, log_object, datastore_object) + else: + apply_smart_update( + record_to_store, + failure_count, + internal_id, + log_object, + datastore_object, + context.documentsToStore, + ) diff --git a/src/eps_spine_shared/interactions/create_prescription.py b/src/eps_spine_shared/interactions/create_prescription.py new file mode 100644 index 0000000..3719b9d --- /dev/null +++ b/src/eps_spine_shared/interactions/create_prescription.py @@ -0,0 +1,262 @@ +import sys +import traceback +from datetime import datetime, timezone + +from eps_spine_shared.common.dynamodb_client import EpsDataStoreError +from eps_spine_shared.common.dynamodb_common import prescription_id_without_check_digit +from eps_spine_shared.common.dynamodb_datastore import EpsDynamoDbDataStore +from eps_spine_shared.common.prescription.record import PrescriptionRecord +from eps_spine_shared.common.prescription.repeat_dispense import RepeatDispenseRecord +from eps_spine_shared.common.prescription.repeat_prescribe import RepeatPrescribeRecord +from eps_spine_shared.common.prescription.single_prescribe import SinglePrescribeRecord +from eps_spine_shared.common.prescription.types import PrescriptionTreatmentType +from eps_spine_shared.errors import ( + EpsBusinessError, + EpsErrorBase, + EpsSystemError, + EpsValidationError, +) +from eps_spine_shared.interactions.common import ( + apply_all_cancellations, + apply_updates, + build_working_record, + check_for_pending_cancellations, + check_for_replay, + create_event_log, + log_pending_cancellation_event, + prepare_document_for_store, + prepare_record_for_store, +) +from eps_spine_shared.logger import EpsLogger +from eps_spine_shared.validation.common import check_mandatory_items +from eps_spine_shared.validation.create import run_validations + +MANDATORY_ITEMS = [ + "agentOrganization", + "agentRoleProfileCodeId", + "hcplOrgCode", + "prescribingGpCode", + "nhsNumber", + "prescriptionID", + "prescriptionTime", + "prescriptionTreatmentType", + "signedTime", + "birthTime", + "agentSdsRole", + "hl7EventID", +] + +CANCELLATION_BODY_XSLT = "cancellationDocument_to_cancellationResponse.xsl" +CANCELLATION_SUCCESS_RESPONSE_TEXT = "Prescription/Item was cancelled" +CANCELLATION_SUCCESS_RESPONSE_CODE = "0001" +CANCEL_SUCCESS_RESPONSE_CODE_SYSTEM = "2.16.840.1.113883.2.1.3.2.4.17.19" +CANCELLATION_SUCCESS_STYLESHEET = "CancellationResponse_PORX_MT135201UK31.xsl" + + +def output_validate(context, internal_id, log_object: EpsLogger): + """ + Validate the WDO using the local validator + """ + try: + check_mandatory_items(context, MANDATORY_ITEMS) + run_validations(context, datetime.now(tz=timezone.utc), internal_id, log_object) + log_object.write_log("EPS0001", None, {"internalID": internal_id}) + except EpsValidationError as e: + last_log_line = traceback.format_tb(sys.exc_info()[2]) + log_object.write_log( + "EPS0002", + None, + { + "internalID": internal_id, + "interactionID": context.interactionID, + "errorDetails": e.supp_info, + "lastLogLine": last_log_line, + }, + ) + # Re-raise this as SpineBusinessError with equivalent errorCode from ErrorBase1634. + raise EpsBusinessError(EpsErrorBase.UNABLE_TO_PROCESS, e.supp_info) from e + + +def audit_prescription_id(prescription_id, interaction_id, internal_id, log_object: EpsLogger): + """ + Log out the inbound prescriptionID - to help with tracing issue by prescriptionID + """ + log_object.write_log( + "EPS0095a", + None, + { + "internalID": internal_id, + "prescriptionID": prescription_id, + "interactionID": interaction_id, + }, + ) + + +def check_for_duplicate( + context, + prescription_id, + internal_id, + log_object: EpsLogger, + data_store_object: EpsDynamoDbDataStore, +): + """ + Check prescription store for existence of prescription + """ + eps_record_id = prescription_id_without_check_digit(prescription_id) + + try: + is_present = data_store_object.is_record_present(internal_id, eps_record_id) + if not is_present: + log_object.write_log( + "EPS0003", None, {"internalID": internal_id, "eps_record_id": eps_record_id} + ) + return + except EpsDataStoreError as e: + log_object.write_log( + "EPS0130", + None, + {"internalID": internal_id, "eps_record_id": eps_record_id, "reason": e.error_topic}, + ) + raise EpsSystemError(EpsSystemError.IMMEDIATE_REQUEUE) from e + + # Prescription present - may be a pending cancellation + try: + record_returned = data_store_object.return_record_for_process(internal_id, eps_record_id) + except EpsDataStoreError as e: + log_object.write_log( + "EPS0130", + None, + {"internalID": internal_id, "eps_record_id": eps_record_id, "reason": e.error_topic}, + ) + raise EpsSystemError(EpsSystemError.IMMEDIATE_REQUEUE) from e + + check_for_late_upload_request(record_returned, internal_id, log_object) + + context.replayDetected = check_for_replay( + eps_record_id, record_returned["value"], context.messageID, context, internal_id, log_object + ) + + if context.replayDetected: + return + + context.recordToProcess = record_returned + if not check_existing_record_real(eps_record_id, context, internal_id, log_object): + log_object.write_log( + "EPS0128a", None, {"internalID": internal_id, "prescriptionID": context.prescriptionID} + ) + + raise EpsSystemError(EpsSystemError.MESSAGE_FAILURE) + + +def check_for_late_upload_request(existing_record, internal_id, log_object: EpsLogger): + """ + It is possible for a cancellation to be received and then for an upload request to follow after over six months. + In this case, the record having a next activity of purge results in an exception upon further processing. + """ + record = PrescriptionRecord(log_object, internal_id) + record.create_record_from_store(existing_record["value"]) + + if record.is_next_activity_purge(): + prescription_id = record.return_prescription_id() + log_object.write_log( + "EPS0818", None, {"prescriptionID": prescription_id, "internalID": internal_id} + ) + # Re-raise this as SpineBusinessError with equivalent errorCode from ErrorBase1722. + raise EpsBusinessError(EpsErrorBase.EXISTS_WITH_NEXT_ACTIVITY_PURGE) + + +def check_existing_record_real(eps_record_id, context, internal_id, log_object: EpsLogger): + """ + Presence of cancellation placeholder has already been confirmed, so now retrieve + the pending cancellation for processing so that the new prescription may overwrite it. + """ + vector_clock = context.recordToProcess["vectorClock"] + log_object.write_log( + "EPS0139", + None, + {"internalID": internal_id, "key": eps_record_id, "vectorClock": vector_clock}, + ) + + build_working_record(context, internal_id, log_object) + + is_prescription = context.epsRecord.check_real() + if is_prescription: + log_object.write_log( + "EPS0128", + None, + {"internalID": internal_id, "prescriptionID": context.prescriptionID}, + ) + # Re-raise this as SpineBusinessError with equivalent errorCode from ErrorBase1722. + raise EpsBusinessError(EpsErrorBase.DUPLICATE_PRESRIPTION) + + # Pending Cancellation + check_for_pending_cancellations(context) + context.cancellationPlaceholderFound = True + context.fetchedRecord = True + return True + + +def create_initial_record(context, internal_id, log_object: EpsLogger): + """ + Create a Prescriptions Record object, and set all initial values + """ + + if context.replayDetected: + return + + treatment_type = context.prescriptionTreatmentType + if treatment_type == PrescriptionTreatmentType.ACUTE_PRESCRIBING: + record_object = SinglePrescribeRecord(log_object, internal_id) + elif treatment_type == PrescriptionTreatmentType.REPEAT_PRESCRIBING: + record_object = RepeatPrescribeRecord(log_object, internal_id) + elif treatment_type == PrescriptionTreatmentType.REPEAT_DISPENSING: + record_object = RepeatDispenseRecord(log_object, internal_id) + else: + log_object.write_log( + "EPS0122", None, {"internalID": internal_id, "treatmentType": treatment_type} + ) + raise EpsSystemError("messageFailure") + + record_object.create_initial_record(context) + context.epsRecord = record_object + context.epsRecord.set_initial_prescription_status(context.handleTime) + + if context.cancellationPlaceholderFound: + apply_all_cancellations(context, internal_id, log_object, was_pending=True) + + +def log_pending_cancellation_events(context, internal_id, log_object: EpsLogger): + """ + Generate pending cancellation eventLog entries for all cancellations on the context + """ + for _ in context.cancellationObjects: + log_pending_cancellation_event(context, None, internal_id, log_object) + + +def prescriptions_workflow( + context, + prescription_id, + interaction_id, + doc_type, + doc_ref_title, + services_dict, + deep_copy, + failure_count, + internal_id, + log_object: EpsLogger, + datastore_object: EpsDynamoDbDataStore, +): + """ + Workflow for creating a prescription + """ + output_validate(context, internal_id, log_object) + audit_prescription_id(prescription_id, interaction_id, internal_id, log_object) + check_for_duplicate(context, prescription_id, internal_id, log_object, datastore_object) + prepare_document_for_store( + context, doc_type, doc_ref_title, services_dict, deep_copy, internal_id, log_object + ) + create_initial_record(context, internal_id, log_object) + log_pending_cancellation_events(context, internal_id, log_object) + create_event_log(context, internal_id, log_object) + prepare_record_for_store(context, internal_id, log_object) + apply_updates(context, failure_count, internal_id, log_object, datastore_object) diff --git a/src/eps_spine_shared/interactions/updates.py b/src/eps_spine_shared/interactions/updates.py new file mode 100644 index 0000000..78fabce --- /dev/null +++ b/src/eps_spine_shared/interactions/updates.py @@ -0,0 +1,145 @@ +from eps_spine_shared.common import indexes +from eps_spine_shared.common.dynamodb_client import EpsDataStoreError +from eps_spine_shared.common.dynamodb_datastore import EpsDynamoDbDataStore +from eps_spine_shared.common.prescription.statuses import PrescriptionStatus +from eps_spine_shared.errors import EpsSystemError +from eps_spine_shared.logger import EpsLogger + + +def apply_smart_update( + object_to_store, + failure_count, + internal_id, + log_object: EpsLogger, + datastore_object: EpsDynamoDbDataStore, + docs_to_store=None, +): + """ + Can be used for inserting a new object, or overwriting an object where + last_write_wins is True + """ + key = object_to_store["key"] + value = object_to_store["value"] + index_dict = object_to_store.get("index") + record_type = object_to_store.get("recordType") + + try: + scn = value.get("SCN") + existing_record = datastore_object.return_record_for_process(internal_id, key) + + is_pending_cancellation = False + existing_record_indexes = existing_record["value"]["indexes"] + prescriber_status_index = existing_record_indexes.get(indexes.INDEX_PRESCRIBER_STATUS) + if prescriber_status_index and len(prescriber_status_index) > 0: + is_pending_cancellation = prescriber_status_index[0].endswith( + PrescriptionStatus.PENDING_CANCELLATION + ) + + if is_pending_cancellation: + existing_scn = existing_record.get("value", {}).get("SCN") + new_scn = existing_scn + 1 if existing_scn else 2 + value["SCN"] = new_scn + scn = new_scn + except Exception: # noqa: BLE001 + scn = None + + try: + datastore_object.insert_eps_record_object( + internal_id, key, value, index_dict, record_type, is_update=True + ) + except EpsDataStoreError as e: + if e.error_topic == EpsDataStoreError.CONDITIONAL_UPDATE_FAILURE: + if failure_count >= 0: + failure_count -= 1 + if docs_to_store: + for doc_to_store in docs_to_store: + doc_key = doc_to_store["key"] + log_object.write_log( + "EPS0126b", None, {"internalID": internal_id, "key": doc_key} + ) + if "notification" not in doc_to_store.get("key", "").lower(): + log_object.write_log( + "EPS0126d", None, {"internalID": internal_id, "key": doc_key} + ) + continue + log_object.write_log( + "EPS0126c", None, {"internalID": internal_id, "key": doc_key} + ) + datastore_object.delete_document( + internal_id, documentKey=doc_key, deleteNotification=True + ) + + log_object.write_log( + "EPS0126a", + None, + { + "internalID": internal_id, + "key": key, + "scn": scn, + "errorCode": e.error_topic, + "vectorClock": object_to_store["vectorClock"], + }, + ) + + raise EpsSystemError(EpsSystemError.IMMEDIATE_REQUEUE) from e + + log_object.write_log( + "EPS0127a", + None, + { + "internalID": internal_id, + "key": key, + "scn": scn, + "vectorClock": object_to_store["vectorClock"], + }, + ) + + +def apply_blind_update( + object_to_store, + bucket, + internal_id, + log_object: EpsLogger, + datastore_object: EpsDynamoDbDataStore, +): + """ + Can be used for inserting a new object, or overwriting an object where + last_write_wins is True + """ + + key = object_to_store["key"] + value = object_to_store["value"] + index_name = object_to_store.get("index") + record_type = object_to_store.get("recordType") + + try: + scn = None + if bucket == "epsRecord": + scn = value.get("SCN") + except Exception: # noqa: BLE001 + scn = None + + try: + if bucket == "epsDocument": + datastore_object.insert_eps_document_object(internal_id, key, value, index_name) + if bucket == "epsRecord": + datastore_object.insert_eps_record_object( + internal_id, key, value, index_name, record_type + ) + except EpsDataStoreError as e: + log_object.write_log( + "EPS0126", + None, + { + "internalID": internal_id, + "bucket": bucket, + "key": key, + "scn": scn, + "errorCode": e.error_topic, + }, + ) + raise EpsSystemError(EpsSystemError.IMMEDIATE_REQUEUE) from e + + log_object.write_log( + "EPS0127", None, {"internalID": internal_id, "bucket": bucket, "key": key, "scn": scn} + ) diff --git a/src/eps_spine_shared/nhsfundamentals/mim_rules.py b/src/eps_spine_shared/nhsfundamentals/mim_rules.py new file mode 100644 index 0000000..42d0264 --- /dev/null +++ b/src/eps_spine_shared/nhsfundamentals/mim_rules.py @@ -0,0 +1,31 @@ +import re + +TEN_DIGIT_NUMBER_REGEX = "^[\\d]{10}$" + + +def is_nhs_number_valid(nhs_number): + """ + Function to check the check digit on a standard nhsNumber + Tenth digit of the NHS number is a check digit + See http://www.datadictionary.nhs.uk/data_dictionary/attributes/n/nhs_number_de.asp + Returns True if check passes, False otherwise + """ + nhs_number_match = False + + if re.match(TEN_DIGIT_NUMBER_REGEX, nhs_number): + + total = 0 + multiplier = 10 + + for i in range(9): + total += int(nhs_number[i]) * multiplier + multiplier -= 1 + + check_digit = 11 - total % 11 + if check_digit == 11: + check_digit = 0 + + if check_digit == int(nhs_number[9]): + nhs_number_match = True + + return nhs_number_match diff --git a/src/eps_spine_shared/nhsfundamentals/time_utilities.py b/src/eps_spine_shared/nhsfundamentals/time_utilities.py new file mode 100644 index 0000000..658c0fa --- /dev/null +++ b/src/eps_spine_shared/nhsfundamentals/time_utilities.py @@ -0,0 +1,196 @@ +import zoneinfo +from datetime import datetime, timedelta + +from eps_spine_shared.logger import EpsLogger + + +class TimeFormats: + STANDARD_DATE_TIME_UTC_ZONE_FORMAT = "%Y%m%d%H%M%S+0000" + STANDARD_DATE_TIME_FORMAT = "%Y%m%d%H%M%S" + STANDARD_DATE_TIME_LENGTH = 14 + DATE_TIME_WITHOUT_SECONDS_FORMAT = "%Y%m%d%H%M" + STANDARD_DATE_FORMAT = "%Y%m%d" + STANDARD_DATE_FORMAT_YEAR_MONTH = "%Y%m" + STANDARD_DATE_FORMAT_YEAR_ONLY = "%Y" + HL7_DATETIME_FORMAT = "%Y%m%dT%H%M%S.%f" + SPINE_DATETIME_MS_FORMAT = "%Y%m%d%H%M%S.%f" + SPINE_DATE_FORMAT = "%Y%m%d" + EBXML_FORMAT = "%Y-%m-%dT%H:%M:%S" + SMSP_FORMAT = "%Y-%m-%dT%H:%M:%SZ" + EXTENDED_SMSP_FORMAT = "%Y-%m-%dT%H:%M:%S.%f" + EXTENDED_SMSP_PLUS_Z_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ" + + +TZ_BST = "BST" +TZ_GMT = "GMT" +TZ_BST_OFFSET = "Etc/GMT-1" +TZ_UTC = "utc" + +_TIMEFORMAT_LENGTH_MAP = { + TimeFormats.STANDARD_DATE_TIME_LENGTH: TimeFormats.STANDARD_DATE_TIME_FORMAT, + 12: TimeFormats.DATE_TIME_WITHOUT_SECONDS_FORMAT, + 8: TimeFormats.STANDARD_DATE_FORMAT, + 6: TimeFormats.STANDARD_DATE_FORMAT_YEAR_MONTH, + 4: TimeFormats.STANDARD_DATE_FORMAT_YEAR_ONLY, + 22: TimeFormats.HL7_DATETIME_FORMAT, + 21: TimeFormats.SPINE_DATETIME_MS_FORMAT, + 20: TimeFormats.SMSP_FORMAT, + 23: TimeFormats.EXTENDED_SMSP_FORMAT, + 26: TimeFormats.EXTENDED_SMSP_FORMAT, + 24: TimeFormats.EXTENDED_SMSP_PLUS_Z_FORMAT, + 27: TimeFormats.EXTENDED_SMSP_PLUS_Z_FORMAT, +} + + +def guess_common_datetime_format(time_string, raise_error_if_unknown=False): + """ + Guess the date time format from the commonly used list + + Args: + time_string (str): + The datetime string to try determine the format of. + raise_error_if_unknown (bool): + Determines the action when the format cannot be determined. + False (default) will return None, True will raise an error. + """ + fmt = None + if len(time_string) == 19: + try: + datetime.strptime(time_string, TimeFormats.EBXML_FORMAT) + fmt = TimeFormats.EBXML_FORMAT + except ValueError: + fmt = TimeFormats.STANDARD_DATE_TIME_UTC_ZONE_FORMAT + else: + fmt = _TIMEFORMAT_LENGTH_MAP.get(len(time_string), None) + + if not fmt and raise_error_if_unknown: + raise ValueError("Could not determine datetime format of '{}'".format(time_string)) + + return fmt + + +def convert_spine_date(date_string, date_format=None): + """ + Try to convert a Spine date using the passed format - if it fails - try the most + appropriate + """ + if date_format: + try: + date_object = datetime.strptime(date_string, date_format) + return date_object + except ValueError: + pass + + date_format = guess_common_datetime_format(date_string, raise_error_if_unknown=True) + return datetime.strptime(date_string, date_format) + + +def date_today_as_string(): + """ + Return the current date as a string in standard format + """ + return time_now_as_string(TimeFormats.STANDARD_DATE_FORMAT) + + +def time_now_as_string(date_format=TimeFormats.STANDARD_DATE_TIME_FORMAT): + """ + Return the current date and time as a string in standard format + """ + return now().strftime(date_format) + + +def now(): + """ + Utility to gets the current date and time. + The intention is for this to be easier to replace when testing. + :returns: a datetime representing the current date and time + """ + return datetime.now() + + +def convert_international_time(international_date, log_object: EpsLogger, internal_id): + """ + Convert a HL7 offset time in BST or GMT format into a 14 digit GMT string, the + allowable international format is: YYYYMMDDHHMMSS[+|-ZZzz], but only +|-0000 and +0100 are permitted + """ + date_format = TimeFormats.STANDARD_DATE_TIME_FORMAT + + if international_date.endswith("+0100"): + # International format BST detected + logged_time_zone = TZ_BST + formatted_date = datetime.strptime(international_date[:14], date_format) + corrected_date = formatted_date.replace(tzinfo=zoneinfo.ZoneInfo(TZ_BST_OFFSET)) + localised_date = corrected_date.astimezone(zoneinfo.ZoneInfo(TZ_GMT)) + returned_date = localised_date.strftime(date_format) + + elif international_date.endswith("+0000") or international_date.endswith("-0000"): + # International format GMT detected + # specifically looking for or - (rather than last four digits of 0000 in case + # of non-international date being passed) + returned_date = international_date[:14] + logged_time_zone = TZ_GMT + else: + # Invalid format detected + log_object.write_log( + "EPS0508", None, {"internalID": internal_id, "datetime": international_date} + ) + raise ValueError + + log_object.write_log( + "EPS0507", + None, + { + "internalID": internal_id, + "datetime": international_date, + "timezone": logged_time_zone, + "convertedDateTime": returned_date, + }, + ) + return returned_date + + +class StopWatch: + """ + Class to support timing points in the code + """ + + def __init__(self): + self.start_time = None + + def start_the_clock(self): + """ + Start the clock + """ + self.start_time = datetime.now() + + def stop_the_clock(self): + """ + Stop the clock automatically resets and restarts the clock + Use split the clock if want to keep a parent timer running + """ + step_duration_seconds = self.split_the_clock() + self.start_time = datetime.now() + return step_duration_seconds + + def split_the_clock(self): + """ + Split the clock, keeping the parent timer running + """ + step_duration = datetime.now() - self.start_time + step_duration_seconds = round( + float(step_duration.seconds) + float(step_duration.microseconds) / 1000000, 3 + ) + if step_duration_seconds < 0.0005: + step_duration_seconds = 0.000 + + return step_duration_seconds + + def reset_the_clock(self, seed_time): + """ + Reset the clock assuming a new seed time, to be used when time has + been passed as message by string Assumed format of time is: + %Y%m%dT%H%M%S.%3N + """ + date_split = seed_time.split(".") + self.start_time = datetime.strptime(date_split[0], "%Y%m%dT%H%M%S") + self.start_time += timedelta(milliseconds=int(date_split[1])) diff --git a/src/eps_spine_shared/nhsfundamentals/timeutilities.py b/src/eps_spine_shared/nhsfundamentals/timeutilities.py deleted file mode 100644 index e9d1092..0000000 --- a/src/eps_spine_shared/nhsfundamentals/timeutilities.py +++ /dev/null @@ -1,93 +0,0 @@ -from datetime import datetime - - -class TimeFormats: - STANDARD_DATE_TIME_UTC_ZONE_FORMAT = "%Y%m%d%H%M%S+0000" - STANDARD_DATE_TIME_FORMAT = "%Y%m%d%H%M%S" - STANDARD_DATE_TIME_LENGTH = 14 - DATE_TIME_WITHOUT_SECONDS_FORMAT = "%Y%m%d%H%M" - STANDARD_DATE_FORMAT = "%Y%m%d" - STANDARD_DATE_FORMAT_YEAR_MONTH = "%Y%m" - STANDARD_DATE_FORMAT_YEAR_ONLY = "%Y" - HL7_DATETIME_FORMAT = "%Y%m%dT%H%M%S.%f" - SPINE_DATETIME_MS_FORMAT = "%Y%m%d%H%M%S.%f" - SPINE_DATE_FORMAT = "%Y%m%d" - EBXML_FORMAT = "%Y-%m-%dT%H:%M:%S" - SMSP_FORMAT = "%Y-%m-%dT%H:%M:%SZ" - EXTENDED_SMSP_FORMAT = "%Y-%m-%dT%H:%M:%S.%f" - EXTENDED_SMSP_PLUS_Z_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ" - - -_TIMEFORMAT_LENGTH_MAP = { - TimeFormats.STANDARD_DATE_TIME_LENGTH: TimeFormats.STANDARD_DATE_TIME_FORMAT, - 12: TimeFormats.DATE_TIME_WITHOUT_SECONDS_FORMAT, - 8: TimeFormats.STANDARD_DATE_FORMAT, - 6: TimeFormats.STANDARD_DATE_FORMAT_YEAR_MONTH, - 4: TimeFormats.STANDARD_DATE_FORMAT_YEAR_ONLY, - 22: TimeFormats.HL7_DATETIME_FORMAT, - 21: TimeFormats.SPINE_DATETIME_MS_FORMAT, - 20: TimeFormats.SMSP_FORMAT, - 23: TimeFormats.EXTENDED_SMSP_FORMAT, - 26: TimeFormats.EXTENDED_SMSP_FORMAT, - 24: TimeFormats.EXTENDED_SMSP_PLUS_Z_FORMAT, - 27: TimeFormats.EXTENDED_SMSP_PLUS_Z_FORMAT, -} - - -def _guess_common_datetime_format(time_string, raise_error_if_unknown=False): - """ - Guess the date time format from the commonly used list - - Args: - time_string (str): - The datetime string to try determine the format of. - raise_error_if_unknown (bool): - Determines the action when the format cannot be determined. - False (default) will return None, True will raise an error. - """ - time_format = None - if len(time_string) == 19: - try: - datetime.strptime(time_string, TimeFormats.EBXML_FORMAT) - time_format = TimeFormats.EBXML_FORMAT - except ValueError: - time_format = TimeFormats.STANDARD_DATE_TIME_UTC_ZONE_FORMAT - else: - time_format = _TIMEFORMAT_LENGTH_MAP.get(len(time_string), None) - - if not time_format and raise_error_if_unknown: - raise ValueError("Could not determine datetime format of '{}'".format(time_string)) - - return time_format - - -def convert_spine_date(date_string, date_format=None): - """ - Try to convert a Spine date using the passed format - if it fails - try the most - appropriate - """ - if date_format: - try: - date_object = datetime.strptime(date_string, date_format) - return date_object - except ValueError: - pass - - date_format = _guess_common_datetime_format(date_string, raise_error_if_unknown=True) - return datetime.strptime(date_string, date_format) - - -def time_now_as_string(date_format=TimeFormats.STANDARD_DATE_TIME_FORMAT): - """ - Return the current date and time as a string in standard format - """ - return now().strftime(date_format) - - -def now(): - """ - Utility to gets the current date and time. - The intention is for this to be easier to replace when testing. - :returns: a datetime representing the current date and time - """ - return datetime.now() diff --git a/src/eps_spine_shared/spinecore/baseutilities.py b/src/eps_spine_shared/spinecore/base_utilities.py similarity index 100% rename from src/eps_spine_shared/spinecore/baseutilities.py rename to src/eps_spine_shared/spinecore/base_utilities.py diff --git a/src/eps_spine_shared/spinecore/changelog.py b/src/eps_spine_shared/spinecore/changelog.py index f0a4c06..10e0ddc 100644 --- a/src/eps_spine_shared/spinecore/changelog.py +++ b/src/eps_spine_shared/spinecore/changelog.py @@ -3,7 +3,7 @@ import uuid from eps_spine_shared.errors import EpsSystemError -from eps_spine_shared.nhsfundamentals.timeutilities import TimeFormats +from eps_spine_shared.nhsfundamentals.time_utilities import TimeFormats class ChangeLogProcessor(object): @@ -287,7 +287,6 @@ def log_for_domain_update(cls, update_context, internal_id): Create a change log for this expected change - requires attribute to be set on context object """ - log_of_change = cls.log_for_general_update( update_context.epsRecord.get_scn(), internal_id, diff --git a/src/eps_spine_shared/validation/common.py b/src/eps_spine_shared/validation/common.py new file mode 100644 index 0000000..52cc175 --- /dev/null +++ b/src/eps_spine_shared/validation/common.py @@ -0,0 +1,166 @@ +import datetime + +from eps_spine_shared.common import checksum_util +from eps_spine_shared.errors import EpsValidationError +from eps_spine_shared.logger import EpsLogger +from eps_spine_shared.nhsfundamentals.mim_rules import is_nhs_number_valid +from eps_spine_shared.nhsfundamentals.time_utilities import ( + TimeFormats, + convert_international_time, +) +from eps_spine_shared.validation import message_vocab +from eps_spine_shared.validation.constants import ( + PERFORMER_TYPELIST, + REGEX_ALPHANUMERIC8, + REGEX_GUID, + REGEX_NUMERIC15, + REGEX_PRESCRID, + REGEX_ROLECODE, +) + + +def check_nominated_performer(context): + """ + If there is nominated performer (i.e. pharmacy) information - then the format + needs to be validated + """ + if context.msgOutput.get(message_vocab.NOMPERFORMER) and context.msgOutput.get( + message_vocab.NOMPERFORMER_TYPE + ): + if not REGEX_ALPHANUMERIC8.match(context.msgOutput.get(message_vocab.NOMPERFORMER)): + raise EpsValidationError("nominatedPerformer has invalid format") + if context.msgOutput.get(message_vocab.NOMPERFORMER_TYPE) not in PERFORMER_TYPELIST: + raise EpsValidationError("nominatedPerformer has invalid type") + + if context.msgOutput.get(message_vocab.NOMPERFORMER) == "": + raise EpsValidationError("nominatedPerformer is present but empty") + + context.outputFields.add(message_vocab.NOMPERFORMER) + context.outputFields.add(message_vocab.NOMPERFORMER_TYPE) + + +def check_prescription_id(context, internal_id, log_object: EpsLogger): + """ + Check the format of a prescription ID and that it has the correct checksum + """ + if not REGEX_PRESCRID.match(context.msgOutput[message_vocab.PRESCID]): + raise EpsValidationError(message_vocab.PRESCID + " has invalid format") + + valid = checksum_util.check_checksum( + context.msgOutput[message_vocab.PRESCID], internal_id, log_object + ) + if not valid: + raise EpsValidationError(message_vocab.PRESCID + " has invalid checksum") + + context.outputFields.add(message_vocab.PRESCID) + + +def check_organisation_and_roles(context, internal_id, log_object: EpsLogger): + """ + Check the organisation and role information is of the correct format + Requires: + agent_organization + agent_role_profile_code_id + agent_sds_role + """ + if not REGEX_ALPHANUMERIC8.match(context.msgOutput[message_vocab.AGENTORG]): + raise EpsValidationError(message_vocab.AGENTORG + " has invalid format") + if not REGEX_NUMERIC15.match(context.msgOutput[message_vocab.ROLEPROFILE]): + log_object.write_log( + "EPS0323b", + None, + { + "internalID": internal_id, + "agent_sds_role_profile_id": context.msgOutput[message_vocab.ROLEPROFILE], + }, + ) + + if context.msgOutput[message_vocab.ROLE] == "NotProvided": + log_object.write_log("EPS0330", None, {"internalID": internal_id}) + elif not REGEX_ROLECODE.match(context.msgOutput[message_vocab.ROLE]): + log_object.write_log( + "EPS0323", + None, + { + "internalID": internal_id, + "agent_sds_role": context.msgOutput[message_vocab.ROLE], + }, + ) + + context.outputFields.add(message_vocab.AGENTORG) + context.outputFields.add(message_vocab.ROLEPROFILE) + context.outputFields.add(message_vocab.ROLE) + + +def check_nhs_number(context): + """ + Check an nhs number is of a valid format + Requires: + nhsNumber + """ + if is_nhs_number_valid(context.msgOutput[message_vocab.PATIENTID]): + context.outputFields.add(message_vocab.PATIENTID) + else: + supp_info = message_vocab.PATIENTID + " is not valid" + raise EpsValidationError(supp_info) + + +def check_standard_date_time(context, attribute_name, internal_id, log_object: EpsLogger): + """ + Check for a valid time + """ + try: + if len(context.msgOutput[attribute_name]) != 14: + if len(context.msgOutput[attribute_name]) != 19: + raise ValueError("Wrong String Length") + parsed_time = convert_international_time( + context.msgOutput[attribute_name], log_object, internal_id + ) + context.msgOutput[attribute_name] = parsed_time + datetime.datetime.strptime( + context.msgOutput[attribute_name], TimeFormats.STANDARD_DATE_TIME_FORMAT + ) + except ValueError as value_error: + supp_info = attribute_name + " is not a valid time or in the " + supp_info += "valid format; expected format " + TimeFormats.STANDARD_DATE_TIME_FORMAT + raise EpsValidationError(supp_info) from value_error + + context.outputFields.add(attribute_name) + + +def check_standard_date(context, attribute_name): + """ + Check for a valid date + """ + try: + if len(context.msgOutput[attribute_name]) != 8: + raise ValueError("Wrong String Length") + datetime.datetime.strptime( + context.msgOutput[attribute_name], TimeFormats.STANDARD_DATE_FORMAT + ) + except ValueError as value_error: + supp_info = attribute_name + " is not a valid time or in the " + supp_info += "valid format; expected format " + TimeFormats.STANDARD_DATE_FORMAT + raise EpsValidationError(supp_info) from value_error + + context.outputFields.add(attribute_name) + + +def check_hl7_event_id(context): + """ + Check a HL7 ID is in a valid UUID format + Requires: + hl7EventID + """ + if not REGEX_GUID.match(context.msgOutput[message_vocab.HL7EVENTID]): + raise EpsValidationError(message_vocab.HL7EVENTID + " has invalid format") + context.outputFields.add(message_vocab.HL7EVENTID) + + +def check_mandatory_items(context, mandatory_extracted_items): + """ + Check for mandatory keys in the schematron output + """ + for mandatory_key in mandatory_extracted_items: + if mandatory_key not in context.msgOutput: + raise EpsValidationError("Mandatory field " + mandatory_key + " missing") diff --git a/src/eps_spine_shared/validation/constants.py b/src/eps_spine_shared/validation/constants.py new file mode 100644 index 0000000..eb1788f --- /dev/null +++ b/src/eps_spine_shared/validation/constants.py @@ -0,0 +1,189 @@ +import re + +from eps_spine_shared.common.prescription import fields + +REGEX_NUMERIC15 = "^[0-9]{1,15}$" +REGEX_ALPHANUMERIC12 = r"^[A-Za-z0-9\-]{1,12}$" +REGEX_ALPHANUMERIC8 = r"^[A-Za-z0-9\-]{1,8}$" +REGEX_ALPHA4 = "^[A-Za-z]{1,4}$" +REGEX_TEXT120 = "^[A-Za-z0-9 \t\r\n\v\f/-]{1,120}$" +REGEX_INTEGER12 = "^[0-9]{1,12}$" + +REGEX_PRESCRID = r"^[A-F0-9]{6}\-[A-Z0-9]{6}\-[A-F0-9]{5}[A-Z0-9\+]{1}$" +REGEX_PRESCRIDR1 = ( + r"^[A-F0-9]{8}\-[A-F0-9]{4}\-[A-F0-9]{4}\-[A-F0-9]{4}\-[A-F0-9]{12}[A-Z0-9\+]{1}$" +) +REGEX_PRESCRIDR1_ALT = r"^[A-F0-9]{8}\-[A-F0-9]{4}\-[A-F0-9]{4}\-[A-F0-9]{4}\-[A-F0-9]{12}$" +REGEX_GUID = r"^[A-F0-9]{8}\-[A-F0-9]{4}\-[A-F0-9]{4}\-[A-F0-9]{4}\-[A-F0-9]{12}$" +REGEX_ROLECODE = r"^[A-Z]{1}[0-9]{4}\:[A-Z]{1}[0-9]{4}\:[A-Z]{1}[0-9]{4}$" +REGEX_ALTROLECODE = r"^[A-Z]{1}[0-9]{4}$" + +REGEX_NUMERIC15 = re.compile(REGEX_NUMERIC15) +REGEX_ALPHANUMERIC12 = re.compile(REGEX_ALPHANUMERIC12) +REGEX_ALPHANUMERIC8 = re.compile(REGEX_ALPHANUMERIC8) +REGEX_ALPHA4 = re.compile(REGEX_ALPHA4) +REGEX_TEXT120 = re.compile(REGEX_TEXT120) +REGEX_INTEGER12 = re.compile(REGEX_INTEGER12) + +REGEX_PRESCRID = re.compile(REGEX_PRESCRID) +REGEX_PRESCRIDR1 = re.compile(REGEX_PRESCRIDR1) +REGEX_PRESCRIDR1_ALT = re.compile(REGEX_PRESCRIDR1_ALT) +REGEX_GUID = re.compile(REGEX_GUID) +REGEX_ROLECODE = re.compile(REGEX_ROLECODE) +REGEX_ALTROLECODE = re.compile(REGEX_ALTROLECODE) + +STATUS_ACUTE = "0001" +STATUS_REPEAT = "0002" +STATUS_REPEAT_DISP = "0003" + +TREATMENT_TYPELIST = [STATUS_ACUTE, STATUS_REPEAT, STATUS_REPEAT_DISP] + +PRESC_TYPELIST = [ + "0001", + "0002", + "0003", + "0004", + "0005", + "0006", + "0007", + "0008", + "0009", + "0010", + "0011", + "0101", + "0102", + "0103", + "0104", + "0105", + "0106", + "0107", + "0108", + "0109", + "0110", + "0113", + "0114", + "0116", + "0117", + "0119", + "0120", + "0121", + "0122", + "0123", + "0124", + "0125", + "0304", + "0305", + "0306", + "0307", + "0406", + "0607", + "0708", + "0709", + "0713", + "0714", + "0716", + "0717", + "0718", + "0719", + "0721", + "0722", + "0901", + "0904", + "0908", + "0913", + "0914", + "0915", + "0916", + "1004", + "1005", + "1008", + "1013", + "1014", + "1016", + "1017", + "1024", + "1025", + "1104", + "1105", + "1108", + "1113", + "1114", + "1116", + "1117", + "1124", + "1125", + "1204", + "1205", + "1208", + "1213", + "1214", + "1216", + "1217", + "1224", + "1225", + "1001", + "1101", + "1201", + "0201", + "0204", + "0205", + "0208", + "0213", + "0214", + "0216", + "0217", + "0224", + "0225", + "2001", + "2004", + "2005", + "2008", + "2013", + "2014", + "2016", + "2017", + "2024", + "2025", + "0707", + "0501", + "0504", + "0505", + "0508", + "0513", + "0514", + "0516", + "0517", + "0524", + "0525", + "5001", + "5004", + "5005", + "5008", + "5013", + "5014", + "5016", + "5017", + "5024", + "5025", +] + +PERFORMER_TYPELIST = ["P1", "P2", "P3"] +WITHDRAW_TYPELIST = ["LD", "AD"] +WITHDRAW_RSONLIST = ["QU", "MU", "DA", "PA", "OC", "ONC"] + +R1 = "R1" +R2 = "R2" + +R1_PRESCID_LENGTHS = [36, 37] +R2_PRESCID_LENGTHS = [18] + +ALT_DATETIME_FORMAT = "%Y%m%d%H%M" + +MAX_LINEITEMS = 4 +MAX_PRESCRIPTIONREPEATS = 99 +DEFAULT_DAYSSUPPLY = fields.DEFAULT_DAYSSUPPLY +MAX_DAYSSUPPLY = 366 +MIN_AGE = 16 +MAX_AGE = 60 +MAX_FUTURESUPPLYMONTHS = 12 +NOT_DISPENSED = "NotDispensedReason" diff --git a/src/eps_spine_shared/validation/create.py b/src/eps_spine_shared/validation/create.py new file mode 100644 index 0000000..6a7c889 --- /dev/null +++ b/src/eps_spine_shared/validation/create.py @@ -0,0 +1,400 @@ +from datetime import datetime + +from dateutil.relativedelta import relativedelta + +from eps_spine_shared.common.prescription.fields import DEFAULT_DAYSSUPPLY +from eps_spine_shared.errors import EpsValidationError +from eps_spine_shared.logger import EpsLogger +from eps_spine_shared.nhsfundamentals.time_utilities import TimeFormats +from eps_spine_shared.validation import constants, message_vocab +from eps_spine_shared.validation.common import ( + check_hl7_event_id, + check_nhs_number, + check_nominated_performer, + check_organisation_and_roles, + check_prescription_id, + check_standard_date, + check_standard_date_time, +) + + +def check_hcpl_org(context): + """ + This is an org only found in EPS2 prescriber details + """ + if not constants.REGEX_ALPHANUMERIC8.match(context.msgOutput[message_vocab.HCPLORG]): + raise EpsValidationError(message_vocab.HCPLORG + " has invalid format") + + +def check_signed_time(context, internal_id, log_object: EpsLogger): + """ + Signed time must be a valid date/time + """ + check_standard_date_time(context, message_vocab.SIGNED_TIME, internal_id, log_object) + + +def check_days_supply(context): + """ + daysSupply is how many days each prescription instance should cover - supports + the calculation of nominated download dates + """ + if not context.msgOutput.get(message_vocab.DAYS_SUPPLY): + context.msgOutput[message_vocab.DAYS_SUPPLY] = DEFAULT_DAYSSUPPLY + else: + if not constants.REGEX_INTEGER12.match(context.msgOutput[message_vocab.DAYS_SUPPLY]): + raise EpsValidationError("daysSupply is not an integer") + days_supply = int(context.msgOutput[message_vocab.DAYS_SUPPLY]) + if days_supply < 0: + raise EpsValidationError("daysSupply must be a non-zero integer") + if days_supply > constants.MAX_DAYSSUPPLY: + raise EpsValidationError("daysSupply cannot exceed " + str(constants.MAX_DAYSSUPPLY)) + # This will need to be an integer when used in the interaction worker + context.msgOutput[message_vocab.DAYS_SUPPLY] = days_supply + + context.outputFields.add(message_vocab.DAYS_SUPPLY) + + +def check_repeat_dispense_window(context, handle_time: datetime): + """ + The overall time to cover the dispense of all repeated instances + + Return immediately if not a repeat dispense, or if a repeat dispense and values + are missing + """ + context.outputFields.add(message_vocab.DAYS_SUPPLY_LOW) + context.outputFields.add(message_vocab.DAYS_SUPPLY_HIGH) + + max_supply_date = handle_time + relativedelta(months=+constants.MAX_FUTURESUPPLYMONTHS) + max_supply_date_string = max_supply_date.strftime(TimeFormats.STANDARD_DATE_FORMAT) + + if context.msgOutput[message_vocab.TREATMENTTYPE] != constants.STATUS_REPEAT_DISP: + context.msgOutput[message_vocab.DAYS_SUPPLY_LOW] = handle_time.strftime( + TimeFormats.STANDARD_DATE_FORMAT + ) + context.msgOutput[message_vocab.DAYS_SUPPLY_HIGH] = max_supply_date_string + return + + if not ( + context.msgOutput.get(message_vocab.DAYS_SUPPLY_LOW) + and context.msgOutput.get(message_vocab.DAYS_SUPPLY_HIGH) + ): + supp_info = "daysSupply effective time not provided but " + supp_info += "prescription treatment type is repeat" + raise EpsValidationError(supp_info) + + check_standard_date(context, message_vocab.DAYS_SUPPLY_HIGH) + check_standard_date(context, message_vocab.DAYS_SUPPLY_LOW) + + if context.msgOutput[message_vocab.DAYS_SUPPLY_HIGH] > max_supply_date_string: + supp_info = "daysSupplyValidHigh is more than " + supp_info += str(constants.MAX_FUTURESUPPLYMONTHS) + " months beyond current day" + raise EpsValidationError(supp_info) + if context.msgOutput[message_vocab.DAYS_SUPPLY_HIGH] < handle_time.strftime( + TimeFormats.STANDARD_DATE_FORMAT + ): + raise EpsValidationError("daysSupplyValidHigh is in the past") + if ( + context.msgOutput[message_vocab.DAYS_SUPPLY_LOW] + > context.msgOutput[message_vocab.DAYS_SUPPLY_HIGH] + ): + raise EpsValidationError("daysSupplyValid low is after daysSupplyValidHigh") + + +def check_prescriber_details(context, internal_id, log_object: EpsLogger): + """ + Validate prescriber details (not required beyond validation). + """ + if not constants.REGEX_ALPHANUMERIC8.match(context.msgOutput[message_vocab.AGENT_PERSON]): + log_object.write_log( + "EPS0323a", + None, + { + "internalID": internal_id, + "prescribingGpCode": context.msgOutput[message_vocab.AGENT_PERSON], + }, + ) + if not constants.REGEX_ALPHANUMERIC12.match(context.msgOutput[message_vocab.AGENT_PERSON]): + raise EpsValidationError(message_vocab.AGENT_PERSON + " has invalid format") + + context.outputFields.add(message_vocab.AGENT_PERSON) + + +def check_patient_name(context): + """ + Adds patient name to the context outputFields + """ + context.outputFields.add(message_vocab.PREFIX) + context.outputFields.add(message_vocab.SUFFIX) + context.outputFields.add(message_vocab.GIVEN) + context.outputFields.add(message_vocab.FAMILY) + + +def check_prescription_treatment_type(context): + """ + Validate treatment type + """ + if context.msgOutput[message_vocab.TREATMENTTYPE] not in constants.TREATMENT_TYPELIST: + supp_info = message_vocab.TREATMENTTYPE + " is not of expected type" + raise EpsValidationError(supp_info) + context.outputFields.add(message_vocab.TREATMENTTYPE) + + +def check_prescription_type(context, internal_id, log_object: EpsLogger): + """ + Validate the prescriptionType + """ + presc_type = context.msgOutput.get(message_vocab.PRESCTYPE) + if presc_type not in constants.PRESC_TYPELIST: + log_object.write_log("EPS0619", None, {"internalID": internal_id, "prescType": presc_type}) + context.msgOutput[message_vocab.PRESCTYPE] = "NotProvided" + + context.outputFields.add(message_vocab.PRESCTYPE) + + +def check_repeat_dispense_instances(context, internal_id, log_object: EpsLogger): + """ + Repeat dispense instances is an integer range found within repeat dispense + prescriptions to articulate the number of instances. Low must be 1! + """ + if not ( + context.msgOutput.get(message_vocab.REPEATLOW) + and context.msgOutput.get(message_vocab.REPEATHIGH) + ): + if context.msgOutput[message_vocab.TREATMENTTYPE] == constants.STATUS_ACUTE: + return + supp_info = message_vocab.REPEATHIGH + " and " + message_vocab.REPEATLOW + supp_info += " values must both be provided if not Acute prescription" + raise EpsValidationError(supp_info) + + if not constants.REGEX_INTEGER12.match(context.msgOutput[message_vocab.REPEATHIGH]): + supp_info = message_vocab.REPEATHIGH + " is not an integer" + raise EpsValidationError(supp_info) + if not constants.REGEX_INTEGER12.match(context.msgOutput[message_vocab.REPEATLOW]): + supp_info = message_vocab.REPEATLOW + " is not an integer" + raise EpsValidationError(supp_info) + + context.msgOutput[message_vocab.REPEATLOW] = int(context.msgOutput[message_vocab.REPEATLOW]) + context.msgOutput[message_vocab.REPEATHIGH] = int(context.msgOutput[message_vocab.REPEATHIGH]) + if context.msgOutput[message_vocab.REPEATLOW] != 1: + supp_info = message_vocab.REPEATLOW + " must be 1" + raise EpsValidationError(supp_info) + if context.msgOutput[message_vocab.REPEATHIGH] > constants.MAX_PRESCRIPTIONREPEATS: + supp_info = message_vocab.REPEATHIGH + " must not be over configured " + supp_info += "maximum of " + str(constants.MAX_PRESCRIPTIONREPEATS) + raise EpsValidationError(supp_info) + if context.msgOutput[message_vocab.REPEATHIGH] < context.msgOutput[message_vocab.REPEATLOW]: + supp_info = message_vocab.REPEATLOW + " is greater than " + message_vocab.REPEATHIGH + raise EpsValidationError(supp_info) + if ( + context.msgOutput[message_vocab.REPEATHIGH] != 1 + and context.msgOutput[message_vocab.TREATMENTTYPE] == constants.STATUS_REPEAT + ): + log_object.write_log( + "EPS0509", + None, + { + "internalID": internal_id, + "target": "Prescription", + "maxRepeats": context.msgOutput[message_vocab.REPEATHIGH], + }, + ) + + context.outputFields.add(message_vocab.REPEATLOW) + context.outputFields.add(message_vocab.REPEATHIGH) + + +def check_birth_date(context, handle_time: datetime): + """ + Birth date must be a valid date, and must not be in the future + """ + check_standard_date(context, message_vocab.BIRTHTIME) + now_as_string = handle_time.strftime(TimeFormats.STANDARD_DATE_TIME_FORMAT) + if context.msgOutput[message_vocab.BIRTHTIME] > now_as_string: + supp_info = message_vocab.BIRTHTIME + " is in the future" + raise EpsValidationError(supp_info) + + +def validate_line_items(context, internal_id, log_object: EpsLogger): + """ + Validating line items - there are up to 32 line items + + Each line item has a GUID (ID) + Each line item may have a repeatLow and a repeatHigh (not one but not the other) + Result needs to be placed onto lineItems dictionary + + Fields may be presented as empty when fields are not present - so these need to be + treated correctly as not present + - To manage this, delete any keys from the dictionary if the result is None or '' + """ + max_repeat_high = 1 + context.msgOutput[message_vocab.LINEITEMS] = [] + + for line_number in range(constants.MAX_LINEITEMS): + line_item = line_number + 1 + line_dict = {} + + line_item_id = message_vocab.LINEITEM_PX + str(line_item) + message_vocab.LINEITEM_SX_ID + if context.msgOutput.get(line_item_id): + line_dict[message_vocab.LINEITEM_DT_ORDER] = line_item + line_dict[message_vocab.LINEITEM_DT_ID] = context.msgOutput[line_item_id] + line_dict[message_vocab.LINEITEM_DT_STATUS] = "0007" + else: + break + + line_item_repeat_high = ( + message_vocab.LINEITEM_PX + str(line_item) + message_vocab.LINEITEM_SX_REPEATHIGH + ) + line_item_repeat_low = ( + message_vocab.LINEITEM_PX + str(line_item) + message_vocab.LINEITEM_SX_REPEATLOW + ) + if context.msgOutput.get(line_item_repeat_high): + line_dict[message_vocab.LINEITEM_DT_MAXREPEATS] = context.msgOutput[ + line_item_repeat_high + ] + if context.msgOutput.get(line_item_repeat_low): + line_dict[message_vocab.LINEITEM_DT_CURRINSTANCE] = context.msgOutput[ + line_item_repeat_low + ] + + max_repeat_high = validate_line_item( + context, line_item, line_dict, max_repeat_high, log_object, internal_id + ) + context.msgOutput[message_vocab.LINEITEMS].append(line_dict) + + if len(context.msgOutput[message_vocab.LINEITEMS]) < 1: + supp_info = "No valid line items found" + raise EpsValidationError(supp_info) + + max_line_item = message_vocab.LINEITEM_PX + max_line_item += str(constants.MAX_LINEITEMS + 1) + max_line_item += message_vocab.LINEITEM_SX_ID + if max_line_item in context.msgOutput: + supp_info = "lineItems over expected max count of " + str(constants.MAX_LINEITEMS) + raise EpsValidationError(supp_info) + + if ( + message_vocab.REPEATHIGH in context.msgOutput + and max_repeat_high < context.msgOutput[message_vocab.REPEATHIGH] + ): + supp_info = "Prescription repeat count must not be greater than all " + supp_info += "Line Item repeat counts" + raise EpsValidationError(supp_info) + + context.outputFields.add(message_vocab.LINEITEMS) + + +def validate_line_item( + context, + line_item, + line_dict, + max_repeat_high, + internal_id, + log_object: EpsLogger, +): + """ + Ensure that the GUID is valid + Check for an appropriate combination of maxRepeats and currentInstance + Check for an appropriate value of maxRepeats + Check for an appropriate value for currentInstance + """ + if not constants.REGEX_GUID.match(line_dict[message_vocab.LINEITEM_DT_ID]): + supp_info = line_dict[message_vocab.LINEITEM_DT_ID] + supp_info += " is not a valid GUID format" + raise EpsValidationError(supp_info) + + if ( + message_vocab.LINEITEM_DT_MAXREPEATS not in line_dict + and message_vocab.LINEITEM_DT_CURRINSTANCE not in line_dict + and context.msgOutput[message_vocab.TREATMENTTYPE] == constants.STATUS_ACUTE + ): + return max_repeat_high + + check_for_invalid_line_item_repeat_combinations(context, line_dict, line_item) + + if not constants.REGEX_INTEGER12.match(line_dict[message_vocab.LINEITEM_DT_MAXREPEATS]): + raise EpsValidationError(f"repeat.High for line item {line_item} is not an integer") + + repeat_high = int(line_dict[message_vocab.LINEITEM_DT_MAXREPEATS]) + if repeat_high < 1: + raise EpsValidationError(f"repeat.High for line item {line_item} must be greater than zero") + if repeat_high > int(context.msgOutput[message_vocab.REPEATHIGH]): + raise EpsValidationError( + f"repeat.High of {repeat_high} for line item {line_item} must not be greater than " + f"{message_vocab.REPEATHIGH} of {context.msgOutput[message_vocab.REPEATHIGH]}" + ) + if ( + repeat_high != 1 + and context.msgOutput[message_vocab.TREATMENTTYPE] == constants.STATUS_REPEAT + ): + log_object.write_log( + "EPS0509", + None, + { + "internalID": internal_id, + "target": str(line_item), + "maxRepeats": repeat_high, + }, + ) + if not constants.REGEX_INTEGER12.match(line_dict[message_vocab.LINEITEM_DT_CURRINSTANCE]): + raise EpsValidationError(f"repeat.Low for line item {line_item} is not an integer") + repeat_low = int(line_dict[message_vocab.LINEITEM_DT_CURRINSTANCE]) + if repeat_low != 1: + raise EpsValidationError(f"repeat.Low for line item {line_item} is not set to 1") + + max_repeat_high = max(max_repeat_high, repeat_high) + return max_repeat_high + + +def check_for_invalid_line_item_repeat_combinations(context, line_dict, line_item): + """ + If not an acute prescription - check the combination of repeat and instance + information is valid + """ + if ( + message_vocab.LINEITEM_DT_MAXREPEATS not in line_dict + and message_vocab.LINEITEM_DT_CURRINSTANCE not in line_dict + ): + raise EpsValidationError( + f"repeat.High and repeat.Low values must both be provided " + f"for lineItem {line_item} if not acute prescription" + ) + elif message_vocab.LINEITEM_DT_MAXREPEATS not in line_dict: + raise EpsValidationError( + f"repeat.Low provided but not repeat.High for line item {line_item}" + ) + elif message_vocab.LINEITEM_DT_CURRINSTANCE not in line_dict: + raise EpsValidationError( + f"repeat.High provided but not repeat.Low for line item {line_item}" + ) + elif not context.msgOutput.get(message_vocab.REPEATHIGH): + raise EpsValidationError( + f"Line item {line_item} repeat value provided for non-repeat prescription" + ) + + +def run_validations(validation_context, handle_time: datetime, internal_id, log_object: EpsLogger): + """ + Validate elements extracted from the inbound message + """ + check_prescriber_details(validation_context, internal_id, log_object) + check_organisation_and_roles(validation_context, internal_id, log_object) + check_nhs_number(validation_context) + check_patient_name(validation_context) + check_standard_date_time(validation_context, message_vocab.PRESCTIME, internal_id, log_object) + check_prescription_treatment_type(validation_context) + check_prescription_type(validation_context, internal_id, log_object) + check_repeat_dispense_instances(validation_context, internal_id, log_object) + check_birth_date(validation_context, handle_time) + check_hl7_event_id(validation_context) + validate_line_items(validation_context, internal_id, log_object) + validation_context.outputFields.add(message_vocab.PRESCSTATUS) + validation_context.msgOutput[message_vocab.PRESCSTATUS] = "NOT_SET_YET" + + check_hcpl_org(validation_context) + check_nominated_performer(validation_context) + check_prescription_id(validation_context, internal_id, log_object) + check_signed_time(validation_context, internal_id, log_object) + check_days_supply(validation_context) + check_repeat_dispense_window(validation_context, handle_time) + validation_context.outputFields.add(message_vocab.SIGNED_INFO) + validation_context.outputFields.add(message_vocab.DIGEST_METHOD) diff --git a/src/eps_spine_shared/validation/message_vocab.py b/src/eps_spine_shared/validation/message_vocab.py new file mode 100644 index 0000000..94ac9fb --- /dev/null +++ b/src/eps_spine_shared/validation/message_vocab.py @@ -0,0 +1,98 @@ +AGENT_PERSON = "agentPersonSDSPerson" +PRESCID = "prescriptionID" +TARGET_PRESCID = "targetPrescriptionID" +TARGET_PRESCVR = "targetPrescriptionVersion" +ROOT_TARGET_PRESCID = "root_targetPrescriptionID" +ROOT_TARGET_PRESCVR = "root_targetPrescriptionVersion" +TARGET_INSTANCE = "targetInstance" +REPEATLOW = "prescriptionRepeatLow" +REPEATHIGH = "prescriptionRepeatHigh" +ROLE = "agentSdsRole" +ROLEPROFILE = "agentRoleProfileCodeId" +AGENTORG = "agentOrganization" +HCPLORG = "hcplOrgCode" +DISPENSER = "dispenserCode" +DISPENSERORG = "dispensingOrganization" +DISPENSERORG_NULL = "dispensingOrgNullFlavor" +PRESCRIBER = "prescribingGpCode" +PATIENTID = "nhsNumber" +HL7EVENTID = "hl7EventID" +LINEITEMS = "lineItems" +LINEITEMS_TOTAL = "totalLineItems" +LINEITEM_REFS = "lineItemRefs" +LINEITEM_PX = "lineItem" +LINEITEM_PX_CLAIM = "dcLineItem" +LINEITEM_PX_DN = "dnLineItem" +LINEITEM_SX_ID = "ID" +LINEITEM_SX_REPEATHIGH = "RepeatHigh" +LINEITEM_SX_REPEATLOW = "RepeatLow" +LINEITEM_SX_STATUS = "Status" +LINEITEM_SX_REF = "Ref" +LINEITEM_DT_ORDER = "order" +LINEITEM_DT_ID = "ID" +LINEITEM_DT_CLAIMID = "DC_ID" +LINEITEM_DT_DNID = "DN_ID" +LINEITEM_DT_STATUS = "status" +LINEITEM_DT_CURRINSTANCE = "currentInstance" +LINEITEM_DT_MAXREPEATS = "maxRepeats" +LINEITEM_DT = "lineDict" +NOMPERFORMER = "nominatedPerformer" +NOMPERFORMER_TYPE = "nominatedPerformerType" +SIGNED_TIME = "signedTime" +CLAIM_TIME = "dispenseClaimTime" +CLAIM_DATE = "claimDate" +CLAIMID = "dispenseClaimID" +DISPENSEN_ID = "dispenseNotificationID" +DISPENSEN_TIME = "dispenseNotificationTime" +DISPENSEW_ID = "dispenseWithdrawID" +DISPENSEW_TIME = "dispenseWithdrawTime" +DISPENSE_TIME = "dispenseTime" +DISPENSE_DATE = "dispenseDate" +DAYS_SUPPLY = "daysSupply" +DAYS_SUPPLY_LOW = "daysSupplyValidLow" +DAYS_SUPPLY_HIGH = "daysSupplyValidHigh" +SIGNED_INFO = "signedInfo" +DIGEST_METHOD = "digestMethod" +PRIMARY_INFO_RCPNT = "primaryInformationRecipient" +REASON_CODE = "returnReasonCode" +REASON_TEXT = "returnReasonText" +CANCEL_CODECOUNT = "cancellationCodeCount" +CANCEL_TYPE = "cancellationType" +CANCEL_TIME = "cancellationTime" +CANCEL_ID = "cancellationID" +CANCEL_LINEITEM = "cancelLineItemRef" +CANCEL_PRESCRIPTION = "cancelPrescriptionRef" +CANCEL_REASONS = "cancellationReasons" +CANCEL_PX_CODE = "cancellationCode" +CANCEL_PX_TEXT = "cancellationText" +RECEIVERORG = "receiverOrg" +UNATTENDEDREQUEST = "unattendedRequest" + +COMPONENTCOUNT = "componentCount" + +DATE_NOMINATEDDOWNLOAD = "nominatedDownloadDate" +DATE_COMPLETION = "completionDate" +DATE_RELEASE = "releaseDate" +DATE_CLAIMSENT = "claimSentDate" +DATE_DISPENSEWINDOWLOW = "dispenseWindowLowDate" +DATE_LASTDISPENSE = "lastDispenseDate" + +PRESCSTATUS = "prescriptionStatus" +PRESCTIME = "prescriptionTime" +TREATMENTTYPE = "prescriptionTreatmentType" +PRESCTYPE = "prescriptionType" +CURRINSTANCE = "currentInstance" +REPLACE_GUID = "replacementOf" +WITHDRAW_GUID = "dispenseNotificationRef" +WITHDRAW_RSON = "withdrawReason" +WITHDRAW_TYPE = "withdrawType" +BIRTHTIME = "birthTime" +PREFIX = "prefix" +SUFFIX = "suffix" +GIVEN = "given" +FAMILY = "family" + +ADMIN_ACTION = "action" +TARGET_DOCUMENT_ID = "targetDocumentID" + +NONDISPENSINGREASON = "nonDispensingReason" diff --git a/tests/common/checksum_util_test.py b/tests/common/checksum_util_test.py new file mode 100644 index 0000000..37e615e --- /dev/null +++ b/tests/common/checksum_util_test.py @@ -0,0 +1,40 @@ +from unittest import TestCase + +from eps_spine_shared.common.checksum_util import check_checksum, remove_check_digit +from eps_spine_shared.testing.mock_logger import MockLogObject + + +class ChecksumUtilTest(TestCase): + def setUp(self): + self.logger = MockLogObject() + + def test_check_checksum_letter(self): + is_valid = check_checksum("7D9625-Z72BF2-11E3AC", "test-internal-id", self.logger) + self.assertTrue(is_valid) + + def test_check_checksum_plus(self): + is_valid = check_checksum("E7ZG38-ZBACYU-V38SR+", "test-internal-id", self.logger) + self.assertTrue(is_valid) + + def test_check_checksum_digit(self): + is_valid = check_checksum("6FOCBU-E776BJ-CMPMT3", "test-internal-id", self.logger) + self.assertTrue(is_valid) + + def test_check_checksum_invalid(self): + is_valid = check_checksum("6FOCBU-E776BJ-CMPMTX", "test-internal-id", self.logger) + self.assertFalse(is_valid) + + def test_remove_check_digit_removes_check_digit_and_preserves_original(self): + prescription_id = "7D9625-Z72BF2-11E3AC" + expected = "7D9625-Z72BF2-11E3A" + result = remove_check_digit(prescription_id) + + self.assertEqual(result, expected) + self.assertEqual(prescription_id, "7D9625-Z72BF2-11E3AC") + + def test_remove_check_digit_only_removes_from_correct_length(self): + prescription_id = "7D9625-Z72BF2-11E3A" + expected = "7D9625-Z72BF2-11E3A" + result = remove_check_digit(prescription_id) + + self.assertEqual(result, expected) diff --git a/tests/common/dynamodb_datastore_test.py b/tests/common/dynamodb_datastore_test.py index e8585f3..327ed21 100644 --- a/tests/common/dynamodb_datastore_test.py +++ b/tests/common/dynamodb_datastore_test.py @@ -24,7 +24,7 @@ ) from eps_spine_shared.common.dynamodb_datastore import EpsDynamoDbDataStore from eps_spine_shared.common.prescription.record import PrescriptionStatus -from eps_spine_shared.nhsfundamentals.timeutilities import TimeFormats +from eps_spine_shared.nhsfundamentals.time_utilities import TimeFormats from eps_spine_shared.testing.mock_logger import MockLogObject from tests.dynamodb_test import DynamoDbTest @@ -760,7 +760,7 @@ def insert_record(datastore: EpsDynamoDbDataStore, insert_args): # Create several processes that try to insert the record concurrently processes = [] loggers = [] - for _ in range(2): + for _ in range(5): logger = MockLogObject() loggers.append(logger) @@ -783,8 +783,8 @@ def insert_record(datastore: EpsDynamoDbDataStore, insert_args): [logs.add(log) for logger in loggers for log in logger.called_references] self.assertTrue("DDB0021" in logs, "Expected a log DDB0021 for concurrent insert failure") - self.assertEqual( - len(exceptions_thrown), 1, "Expected exception to be thrown for concurrent insertions" + self.assertTrue( + len(exceptions_thrown) > 0, "Expected exception to be thrown for concurrent insertions" ) self.assertTrue( isinstance(exceptions_thrown[0], EpsDataStoreError), diff --git a/tests/common/prescription/build_indexes_test.py b/tests/common/prescription/build_indexes_test.py index 53b2793..db8bfa7 100644 --- a/tests/common/prescription/build_indexes_test.py +++ b/tests/common/prescription/build_indexes_test.py @@ -13,7 +13,6 @@ def setUp(self): """ Set up all valid values - tests will overwrite these where required. """ - mock = Mock() attrs = {"writeLog.return_value": None} mock.configure_mock(**attrs) diff --git a/tests/common/prescription/include_next_activity_for_instance_test.py b/tests/common/prescription/include_next_activity_for_instance_test.py index 668d48d..cd3fd20 100644 --- a/tests/common/prescription/include_next_activity_for_instance_test.py +++ b/tests/common/prescription/include_next_activity_for_instance_test.py @@ -14,7 +14,6 @@ def setUp(self): """ Set up all valid values - tests will overwrite these where required. """ - mock = Mock() attrs = {"writeLog.return_value": None} mock.configure_mock(**attrs) diff --git a/tests/common/prescription/next_activity_generator_test.py b/tests/common/prescription/next_activity_generator_test.py index 0f8fa76..077e7d4 100644 --- a/tests/common/prescription/next_activity_generator_test.py +++ b/tests/common/prescription/next_activity_generator_test.py @@ -64,7 +64,6 @@ def test_next_activity_date_scenario_1(self): Unit test for Next Activity and Next Activity Date Generator: Acute - expiry falls 31st -> 1st """ - self.nad_status["prescriptionTreatmentType"] = "0001" self.nad_status["prescriptionStatus"] = "0001" self.nad_status["prescriptionDate"] = "20111031" @@ -75,7 +74,6 @@ def test_next_activity_date_scenario_2(self): Unit test for Next Activity and Next Activity Date Generator: Acute - expiry falls 29th Feb 2012 """ - self.nad_status["prescriptionTreatmentType"] = "0001" self.nad_status["prescriptionStatus"] = "0001" self.nad_status["prescriptionDate"] = "20110829" @@ -86,7 +84,6 @@ def test_next_activity_date_scenario_3(self): Unit test for Next Activity and Next Activity Date Generator: Repeat Prescribe - expiry falls 31st -> 1st """ - self.nad_status["prescriptionTreatmentType"] = "0002" self.nad_status["prescriptionStatus"] = "0001" self.nad_status["prescriptionDate"] = "20111031" @@ -97,7 +94,6 @@ def test_next_activity_date_scenario_4(self): Unit test for Next Activity and Next Activity Date Generator: Repeat Prescribe - expiry falls 29th Feb 2012 """ - self.nad_status["prescriptionTreatmentType"] = "0002" self.nad_status["prescriptionStatus"] = "0001" self.nad_status["prescriptionDate"] = "20110829" @@ -108,7 +104,6 @@ def test_next_activity_date_scenario_5(self): Unit test for Next Activity and Next Activity Date Generator: Repeat Dispense - expiry falls 31st -> 1st """ - self.nad_status["prescriptionTreatmentType"] = "0003" self.nad_status["prescriptionStatus"] = "0001" self.nad_status["prescriptionDate"] = "20111031" @@ -120,7 +115,6 @@ def test_next_activity_date_scenario_6(self): Unit test for Next Activity and Next Activity Date Generator: Repeat Dispense - check that expiry is not limited by Dispense Window """ - self.nad_status["prescriptionTreatmentType"] = "0003" self.nad_status["prescriptionStatus"] = "0001" self.nad_status["prescriptionDate"] = "20120131" @@ -132,7 +126,6 @@ def test_next_activity_date_scenario_7(self): Unit test for Next Activity and Next Activity Date Generator: Acute - expiry falls 29th Feb 2012 """ - self.nad_status["prescriptionTreatmentType"] = "0001" self.nad_status["prescriptionStatus"] = "0002" self.nad_status["prescriptionDate"] = "20110829" @@ -143,7 +136,6 @@ def test_next_activity_date_scenario_8(self): Unit test for Next Activity and Next Activity Date Generator: Acute - expiry falls 31st -> 1st """ - self.nad_status["prescriptionTreatmentType"] = "0001" self.nad_status["prescriptionStatus"] = "0002" self.nad_status["prescriptionDate"] = "20111031" @@ -154,7 +146,6 @@ def test_next_activity_date_scenario_9(self): Unit test for Next Activity and Next Activity Date Generator: Repeat Prescribe - expiry falls 29th Feb 2012 """ - self.nad_status["prescriptionTreatmentType"] = "0002" self.nad_status["prescriptionStatus"] = "0002" self.nad_status["prescriptionDate"] = "20110829" @@ -165,7 +156,6 @@ def test_next_activity_date_scenario_10(self): Unit test for Next Activity and Next Activity Date Generator: Repeat Prescribe - expiry falls 31st -> 1st """ - self.nad_status["prescriptionTreatmentType"] = "0002" self.nad_status["prescriptionStatus"] = "0002" self.nad_status["prescriptionDate"] = "20111031" @@ -176,7 +166,6 @@ def test_next_activity_date_scenario_11(self): Unit test for Next Activity and Next Activity Date Generator: Repeat Dispense - expiry falls 29th Feb 2012 """ - self.nad_status["prescriptionTreatmentType"] = "0003" self.nad_status["prescriptionStatus"] = "0002" self.nad_status["prescriptionDate"] = "20110829" @@ -188,7 +177,6 @@ def test_next_activity_date_scenario_12(self): Unit test for Next Activity and Next Activity Date Generator: Repeat Dispense - expiry falls 31st -> 1st """ - self.nad_status["prescriptionTreatmentType"] = "0003" self.nad_status["prescriptionStatus"] = "0002" self.nad_status["prescriptionDate"] = "20111031" @@ -200,7 +188,6 @@ def test_next_activity_date_scenario_13(self): Unit test for Next Activity and Next Activity Date Generator: Repeat Dispense - check that expiry is not limited by Dispense Window """ - self.nad_status["prescriptionTreatmentType"] = "0003" self.nad_status["prescriptionStatus"] = "0002" self.nad_status["prescriptionDate"] = "20111031" @@ -212,7 +199,6 @@ def test_next_activity_date_scenario_14(self): Unit test for Next Activity and Next Activity Date Generator: Acute - expiry falls 29th Feb 2012 """ - self.nad_status["prescriptionTreatmentType"] = "0001" self.nad_status["prescriptionStatus"] = "0003" self.nad_status["prescriptionDate"] = "20110829" @@ -224,7 +210,6 @@ def test_next_activity_date_scenario_14b(self): Unit test for Next Activity and Next Activity Date Generator: Acute R1 - expiry falls 29th Feb 2012 """ - self.nad_status["prescriptionTreatmentType"] = "0001" self.nad_status["prescriptionStatus"] = "0003" self.nad_status["prescriptionDate"] = "20110829" @@ -237,7 +222,6 @@ def test_next_activity_date_scenario_15(self): Unit test for Next Activity and Next Activity Date Generator: Acute - expiry falls 31st -> 1st """ - self.nad_status["prescriptionTreatmentType"] = "0001" self.nad_status["prescriptionStatus"] = "0003" self.nad_status["prescriptionDate"] = "20111031" @@ -249,7 +233,6 @@ def test_next_activity_date_scenario_15b(self): Unit test for Next Activity and Next Activity Date Generator: Acute R1 - expiry falls 31st -> 1st """ - self.nad_status["prescriptionTreatmentType"] = "0001" self.nad_status["prescriptionStatus"] = "0003" self.nad_status["prescriptionDate"] = "20111031" @@ -262,7 +245,6 @@ def test_next_activity_date_scenario_16(self): Unit test for Next Activity and Next Activity Date Generator: Repeat Prescribe - expiry falls 29th Feb 2012 """ - self.nad_status["prescriptionTreatmentType"] = "0002" self.nad_status["prescriptionStatus"] = "0003" self.nad_status["prescriptionDate"] = "20110829" @@ -274,7 +256,6 @@ def test_next_activity_date_scenario_17(self): Unit test for Next Activity and Next Activity Date Generator: Repeat Prescribe - expiry falls 31st -> 1st """ - self.nad_status["prescriptionTreatmentType"] = "0002" self.nad_status["prescriptionStatus"] = "0003" self.nad_status["prescriptionDate"] = "20111031" @@ -286,7 +267,6 @@ def test_next_activity_date_scenario_18(self): Unit test for Next Activity and Next Activity Date Generator: Repeat Dispense - expiry falls 29th Feb 2012 """ - self.nad_status["prescriptionTreatmentType"] = "0003" self.nad_status["prescriptionStatus"] = "0003" self.nad_status["prescriptionDate"] = "20110829" @@ -299,7 +279,6 @@ def test_next_activity_date_scenario_19(self): Unit test for Next Activity and Next Activity Date Generator: Repeat Dispense - expiry falls 31st -> 1st """ - self.nad_status["prescriptionTreatmentType"] = "0003" self.nad_status["prescriptionStatus"] = "0003" self.nad_status["prescriptionDate"] = "20111031" @@ -312,7 +291,6 @@ def test_next_activity_date_scenario_20(self): Unit test for Next Activity and Next Activity Date Generator: Repeat Dispense - check that expiry date is not limited by Dispense Window """ - self.nad_status["prescriptionTreatmentType"] = "0003" self.nad_status["prescriptionStatus"] = "0003" self.nad_status["prescriptionDate"] = "20111031" @@ -325,7 +303,6 @@ def test_next_activity_date_scenario_21(self): Unit test for Next Activity and Next Activity Date Generator: Repeat Dispense - no claim window falls before expiry """ - self.nad_status["prescriptionTreatmentType"] = "0003" self.nad_status["prescriptionStatus"] = "0003" self.nad_status["prescriptionDate"] = "20111031" @@ -338,7 +315,6 @@ def test_next_activity_date_scenario_22(self): Unit test for Next Activity and Next Activity Date Generator: Acute - expiry falls 29th Feb 2012 """ - self.nad_status["prescriptionTreatmentType"] = "0001" self.nad_status["prescriptionStatus"] = "0004" self.nad_status["prescriptionDate"] = "20110729" @@ -350,7 +326,6 @@ def test_next_activity_date_scenario_23(self): Unit test for Next Activity and Next Activity Date Generator: Repeat Prescribe - expiry falls 29th Feb 2012 """ - self.nad_status["prescriptionTreatmentType"] = "0002" self.nad_status["prescriptionStatus"] = "0004" self.nad_status["prescriptionDate"] = "20110729" @@ -362,7 +337,6 @@ def test_next_activity_date_scenario_24(self): Unit test for Next Activity and Next Activity Date Generator: Repeat Dispense - expiry falls 29th Feb 2012 """ - self.nad_status["prescriptionTreatmentType"] = "0003" self.nad_status["prescriptionStatus"] = "0004" self.nad_status["prescriptionDate"] = "20110729" @@ -374,7 +348,6 @@ def test_next_activity_date_scenario_25(self): Unit test for Next Activity and Next Activity Date Generator: Acute - expiry falls 29th Feb 2012 """ - self.nad_status["prescriptionTreatmentType"] = "0001" self.nad_status["prescriptionStatus"] = "0005" self.nad_status["prescriptionDate"] = "20110729" @@ -387,7 +360,6 @@ def test_next_activity_date_scenario_25a(self): Specific test for migrated data scenario where completionDate is false not a valid date. """ - self.nad_status["prescriptionTreatmentType"] = "0001" self.nad_status["prescriptionStatus"] = "0005" self.nad_status["prescriptionDate"] = "20110729" @@ -400,7 +372,6 @@ def test_next_activity_date_scenario_26(self): Unit test for Next Activity and Next Activity Date Generator: Repeat Prescribe - expiry falls 29th Feb 2012 """ - self.nad_status["prescriptionTreatmentType"] = "0002" self.nad_status["prescriptionStatus"] = "0005" self.nad_status["prescriptionDate"] = "20110729" @@ -412,7 +383,6 @@ def test_next_activity_date_scenario_27(self): Unit test for Next Activity and Next Activity Date Generator: Repeat Dispense - expiry falls 29th Feb 2012 """ - self.nad_status["prescriptionTreatmentType"] = "0003" self.nad_status["prescriptionStatus"] = "0005" self.nad_status["prescriptionDate"] = "20110729" @@ -424,7 +394,6 @@ def test_next_activity_date_scenario_28(self): Unit test for Next Activity and Next Activity Date Generator: Acute - expiry falls 29th Feb 2012 """ - self.nad_status["prescriptionTreatmentType"] = "0001" self.nad_status["prescriptionStatus"] = "0006" self.nad_status["prescriptionDate"] = "20110729" @@ -438,7 +407,6 @@ def test_next_activity_date_scenario_28b(self): Unit test for Next Activity and Next Activity Date Generator: Acute R1 - expiry falls 29th Feb 2012 """ - self.nad_status["prescriptionTreatmentType"] = "0001" self.nad_status["prescriptionStatus"] = "0006" self.nad_status["prescriptionDate"] = "20110729" @@ -453,7 +421,6 @@ def test_next_activity_date_scenario_29(self): Unit test for Next Activity and Next Activity Date Generator: Acute - expiry falls 31st -> 1st """ - self.nad_status["prescriptionTreatmentType"] = "0001" self.nad_status["prescriptionStatus"] = "0006" self.nad_status["prescriptionDate"] = "20110331" @@ -467,7 +434,6 @@ def test_next_activity_date_scenario_30(self): Unit test for Next Activity and Next Activity Date Generator: Repeat Prescribe - expiry falls 29th Feb 2012 """ - self.nad_status["prescriptionTreatmentType"] = "0002" self.nad_status["prescriptionStatus"] = "0006" self.nad_status["prescriptionDate"] = "20110729" @@ -481,7 +447,6 @@ def test_next_activity_date_scenario_31(self): Unit test for Next Activity and Next Activity Date Generator: Acute - expiry falls 29th Feb 2012 """ - self.nad_status["prescriptionTreatmentType"] = "0001" self.nad_status["prescriptionStatus"] = "0007" self.nad_status["prescriptionDate"] = "20110729" @@ -493,7 +458,6 @@ def test_next_activity_date_scenario_32(self): Unit test for Next Activity and Next Activity Date Generator: Repeat Prescribe - expiry falls 29th Feb 2012 """ - self.nad_status["prescriptionTreatmentType"] = "0002" self.nad_status["prescriptionStatus"] = "0007" self.nad_status["prescriptionDate"] = "20110729" @@ -505,7 +469,6 @@ def test_next_activity_date_scenario_33(self): Unit test for Next Activity and Next Activity Date Generator: Repeat Dispense - expiry falls 29th Feb 2012 """ - self.nad_status["prescriptionTreatmentType"] = "0003" self.nad_status["prescriptionStatus"] = "0007" self.nad_status["prescriptionDate"] = "20110729" @@ -517,7 +480,6 @@ def test_next_activity_date_scenario_34(self): Unit test for Next Activity and Next Activity Date Generator: Acute - expiry falls 29th Feb 2012 """ - self.nad_status["prescriptionTreatmentType"] = "0001" self.nad_status["prescriptionStatus"] = "0008" self.nad_status["prescriptionDate"] = "20110731" @@ -530,7 +492,6 @@ def test_next_activity_date_scenario_37(self): Unit test for Next Activity and Next Activity Date Generator: Acute - expiry falls 29th Feb 2012 """ - self.nad_status["prescriptionTreatmentType"] = "0001" self.nad_status["prescriptionStatus"] = "0009" self.nad_status["prescriptionDate"] = "20110731" @@ -543,7 +504,6 @@ def test_next_activity_date_scenario_38(self): Unit test for Next Activity and Next Activity Date Generator: Repeat Prescribe - expiry falls 29th Feb 2012 """ - self.nad_status["prescriptionTreatmentType"] = "0002" self.nad_status["prescriptionStatus"] = "0009" self.nad_status["prescriptionDate"] = "20110731" @@ -556,7 +516,6 @@ def test_next_activity_date_scenario_39(self): Unit test for Next Activity and Next Activity Date Generator: Repeat Dispense - expiry falls 29th Feb 2012 """ - self.nad_status["prescriptionTreatmentType"] = "0003" self.nad_status["prescriptionStatus"] = "0009" self.nad_status["prescriptionDate"] = "20110731" @@ -569,7 +528,6 @@ def test_next_activity_date_scenario_40(self): Unit test for Next Activity and Next Activity Date Generator: Repeat Prescribe - Nominated Release before Expiry """ - self.nad_status["prescriptionTreatmentType"] = "0002" self.nad_status["prescriptionStatus"] = "0000" self.nad_status["prescriptionDate"] = "20120731" @@ -581,7 +539,6 @@ def test_next_activity_date_scenario_41(self): Unit test for Next Activity and Next Activity Date Generator: Repeat Prescribe - Expiry before Nominated Release """ - self.nad_status["prescriptionTreatmentType"] = "0002" self.nad_status["prescriptionStatus"] = "0000" self.nad_status["prescriptionDate"] = "20110731" @@ -593,7 +550,6 @@ def test_next_activity_date_scenario_42(self): Unit test for Next Activity and Next Activity Date Generator: Repeat Dispense - Nominated Release falls 29th Feb 2012 """ - self.nad_status["prescriptionTreatmentType"] = "0003" self.nad_status["prescriptionStatus"] = "0000" self.nad_status["prescriptionDate"] = "20111101" @@ -605,7 +561,6 @@ def test_next_activity_date_scenario_43(self): Unit test for Next Activity and Next Activity Date Generator: Repeat Dispense - Expiry falls 30th Sep 2011 """ - self.nad_status["prescriptionTreatmentType"] = "0003" self.nad_status["prescriptionStatus"] = "0000" self.nad_status["prescriptionDate"] = "20110331" diff --git a/tests/common/prescription/record_test.py b/tests/common/prescription/record_test.py index 1f0a53d..4a5b1d6 100644 --- a/tests/common/prescription/record_test.py +++ b/tests/common/prescription/record_test.py @@ -4,6 +4,9 @@ from unittest.case import TestCase from unittest.mock import MagicMock +from parameterized.parameterized import parameterized + +from eps_spine_shared.common import indexes from eps_spine_shared.common.prescription import fields from eps_spine_shared.common.prescription.record import PrescriptionRecord from eps_spine_shared.common.prescription.repeat_dispense import RepeatDispenseRecord @@ -11,7 +14,7 @@ from eps_spine_shared.common.prescription.single_prescribe import SinglePrescribeRecord from eps_spine_shared.common.prescription.types import PrescriptionTreatmentType from eps_spine_shared.errors import EpsSystemError -from eps_spine_shared.nhsfundamentals.timeutilities import TimeFormats +from eps_spine_shared.nhsfundamentals.time_utilities import TimeFormats from eps_spine_shared.testing.mock_logger import MockLogObject @@ -268,7 +271,6 @@ def test_reset_current_instance(self): """ Test that resetting the current instance chooses the correct instance. """ - prescription = load_test_example_json(self.mock_log_object, "50EE48-B83002-490F7.json") self.assertEqual(prescription.current_issue_number, 4) (old, new) = prescription.reset_current_instance() @@ -369,7 +371,6 @@ def test_set_initial_prescription_status_future_dated(self): """ Test that a prescription with a future start date is marked as FUTURE_DATED_PRESCRIPTION. """ - prescription = load_test_example_json(self.mock_log_object, "0DA698-A83008-F50593.json") future_time = datetime.now() + timedelta(days=10) @@ -377,6 +378,101 @@ def test_set_initial_prescription_status_future_dated(self): self.assertEqual(prescription.get_issue(1).status, "9001") + def test_add_index_to_record(self): + """ + Test that we can add an index to the record. + """ + prescription = PrescriptionRecord(self.mock_log_object, "test") + prescription.prescription_record = {} + + prescription.add_index_to_record({"testIndex": "testValue"}) + + self.assertEqual( + prescription.prescription_record.get(fields.FIELD_INDEXES), {"testIndex": "testValue"} + ) + + def test_increment_scn(self): + """ + Test that we can increment the SCN. + """ + prescription = PrescriptionRecord(self.mock_log_object, "test") + prescription.prescription_record = {"SCN": 5} + + prescription.increment_scn() + + self.assertEqual(prescription.prescription_record.get("SCN"), 6) + + def test_add_document_refs(self): + """ + Test that we can add document refs. + """ + prescription = PrescriptionRecord(self.mock_log_object, "test") + prescription.prescription_record = {} + + prescription.add_document_references(["doc1", "doc2"]) + + self.assertEqual( + prescription.prescription_record.get(fields.FIELDS_DOCUMENTS), ["doc1", "doc2"] + ) + + @parameterized.expand( + [ + ("upper", indexes.INDEX_NEXTACTIVITY, "next_activity_nad"), + ("lower", indexes.INDEX_NEXTACTIVITY.lower(), "next_activity_nad"), + ("invalid", "invalid_index", None), + ] + ) + def test_return_next_activity_nad_bin(self, _, index_key, expected): + """ + Test that we can return the next activity NAD bin. + """ + prescription = PrescriptionRecord(self.mock_log_object, "test") + prescription.prescription_record = {fields.FIELD_INDEXES: {index_key: "next_activity_nad"}} + + nad_bin = prescription.return_next_activity_nad_bin() + + self.assertEqual(nad_bin, expected) + + def test_name_map_on_create(self): + """ + Test that the names are mapped correctly when creating a record. + """ + context = MagicMock() + context.agentOrganization = "testOrg" + context.prescriptionRepeatHigh = "repeatHigh" + context.daysSupplyValidLow = "daysLow" + context.daysSupplyValidHigh = "daysHigh" + prescription = PrescriptionRecord(self.mock_log_object, "test") + + prescription.name_map_on_create(context) + + self.assertEqual(context.prescribingOrganization, "testOrg") + self.assertEqual(context.maxRepeats, "repeatHigh") + self.assertEqual(context.dispenseWindowLowDate, "daysLow") + self.assertEqual(context.dispenseWindowHighDate, "daysHigh") + + def test_return_prechange_issue_status_dict(self): + """ + Test that we can return the pre-change issue status dict. + """ + prescription = PrescriptionRecord(self.mock_log_object, "test") + prescription.pre_change_issue_status_dict = "pre_change_status_dict" + + result = prescription.return_prechange_issue_status_dict() + + self.assertEqual(result, "pre_change_status_dict") + + def test_return_prechange_current_issue(self): + """ + Test that we can return the pre-change current issue. + """ + prescription = PrescriptionRecord(self.mock_log_object, "test") + prescription.pre_change_current_issue = "pre_change_current_issue" + + result = prescription.return_prechange_current_issue() + + self.assertEqual(result, "pre_change_current_issue") + class PrescriptionRecordChangeLogTest(TestCase): """ diff --git a/tests/common/prescription/return_changed_issue_list_test.py b/tests/common/prescription/return_changed_issue_list_test.py index f9015fa..b52bbe0 100644 --- a/tests/common/prescription/return_changed_issue_list_test.py +++ b/tests/common/prescription/return_changed_issue_list_test.py @@ -13,7 +13,6 @@ def setUp(self): """ Set up all valid values - tests will overwrite these where required. """ - mock = Mock() attrs = {"writeLog.return_value": None} mock.configure_mock(**attrs) diff --git a/tests/interactions/updates_test.py b/tests/interactions/updates_test.py new file mode 100644 index 0000000..1335383 --- /dev/null +++ b/tests/interactions/updates_test.py @@ -0,0 +1,264 @@ +from unittest.mock import Mock + +from eps_spine_shared.common import indexes +from eps_spine_shared.common.dynamodb_client import EpsDataStoreError +from eps_spine_shared.common.dynamodb_common import SortKey +from eps_spine_shared.common.prescription.statuses import PrescriptionStatus +from eps_spine_shared.errors import EpsSystemError +from eps_spine_shared.interactions.updates import apply_blind_update, apply_smart_update +from tests.dynamodb_test import DynamoDbTest + + +class BlindUpdateTest(DynamoDbTest): + """ + Tests of the blind update function + """ + + def setUp(self): + super().setUp() + self.internal_id = "test-internal-id" + + def test_blind_insert_document(self): + """ + Test a happy path insert of a document + """ + document_key = self.generate_document_key() + content = self.get_document_content() + document = {"content": content} + + object_to_store = { + "key": document_key, + "value": document, + } + + apply_blind_update( + object_to_store, + "epsDocument", + self.internal_id, + self.logger, + self.datastore, + ) + + self.assertTrue( + "EPS0127" in self.logger.called_references, "Expected EPS0127 log entry not found" + ) + + def test_blind_insert_record(self): + """ + Test a happy path insert of a record + """ + prescription_id, nhs_number = self.get_new_record_keys() + record = self.get_record(nhs_number) + + object_to_store = {"key": prescription_id, "value": record} + + apply_blind_update( + object_to_store, + "epsRecord", + self.internal_id, + self.logger, + self.datastore, + ) + + self.assertTrue( + "EPS0127" in self.logger.called_references, "Expected EPS0127 log entry not found" + ) + + def test_insert_failure(self): + """ + Test a failure to insert a record + """ + + def throw_data_store_error(*_): + raise EpsDataStoreError( + self.datastore.client, None, EpsDataStoreError.CONDITIONAL_UPDATE_FAILURE + ) + + self.datastore.insert_eps_record_object = Mock(side_effect=throw_data_store_error) + + prescription_id, nhs_number = self.get_new_record_keys() + record = self.get_record(nhs_number) + + object_to_store = {"key": prescription_id, "value": record} + + with self.assertRaises(EpsSystemError) as cm: + apply_blind_update( + object_to_store, "epsRecord", self.internal_id, self.logger, self.datastore + ) + + self.assertEqual( + cm.exception.error_topic, + EpsSystemError.IMMEDIATE_REQUEUE, + "Expected EpsSystemError with IMMEDIATE_REQUEUE topic not raised", + ) + + self.assertTrue( + "EPS0126" in self.logger.called_references, "Expected EPS0126 log entry not found" + ) + + +class SmartUpdateTest(DynamoDbTest): + """ + Test of the smart update function + """ + + def setUp(self): + """ + Assigns aliases for update applier bindings + """ + super().setUp() + self.internal_id = "test-internal-id" + + def test_update(self): + """ + Test a happy path update of a record + """ + prescription_id, nhs_number = self.get_new_record_keys() + record = self.get_record(nhs_number) + + self.datastore.insert_eps_record_object(self.internal_id, prescription_id, record) + + record["SCN"] += 1 + object_to_store = {"key": prescription_id, "value": record, "vectorClock": None} + + apply_smart_update(object_to_store, 0, self.internal_id, self.logger, self.datastore) + + self.assertTrue( + "EPS0127a" in self.logger.called_references, "Expected EPS0127a log entry not found" + ) + + def test_update_pending_cancellation_with_scn(self): + """ + Test an update of a record that is pending cancellation + and has a set SCN + """ + prescription_id, nhs_number = self.get_new_record_keys() + record = self.get_record(nhs_number) + + record["indexes"][indexes.INDEX_PRESCRIBER_STATUS] = [ + f"_{PrescriptionStatus.PENDING_CANCELLATION}" + ] + record["SCN"] = 5 + self.datastore.insert_eps_record_object(self.internal_id, prescription_id, record) + + object_to_store = {"key": prescription_id, "value": record, "vectorClock": None} + + apply_smart_update(object_to_store, 0, self.internal_id, self.logger, self.datastore) + + self.assertTrue( + "EPS0127a" in self.logger.called_references, "Expected EPS0127a log entry not found" + ) + self.assertEqual(self.logger.logged_messages[8][1]["scn"], 6) + + def test_update_pending_cancellation_without_scn(self): + """ + Test an update of a record that is pending cancellation + and does not have a set SCN + """ + prescription_id, nhs_number = self.get_new_record_keys() + record = self.get_record(nhs_number) + + record["indexes"][indexes.INDEX_PRESCRIBER_STATUS] = [ + f"_{PrescriptionStatus.PENDING_CANCELLATION}" + ] + record["SCN"] = 0 + self.datastore.insert_eps_record_object(self.internal_id, prescription_id, record) + + object_to_store = {"key": prescription_id, "value": record, "vectorClock": None} + + apply_smart_update(object_to_store, 0, self.internal_id, self.logger, self.datastore) + + self.assertTrue( + "EPS0127a" in self.logger.called_references, "Expected EPS0127a log entry not found" + ) + self.assertEqual(self.logger.logged_messages[8][1]["scn"], 2) + + def test_conditional_update_failure(self): + """ + Test a conditional update failure will raise an immediate requeue and delete + inserted documents correctly + """ + self._balanceIncrementInFailureCount = Mock() + + # Insert documents to test deletion on failure + document_key = self.generate_document_key() + content = self.get_document_content() + document = {"content": content} + + docs_to_store = [ + {"key": key} + for key in [document_key, self.datastore.NOTIFICATION_PREFIX + document_key] + ] + self.keys.append((docs_to_store[1]["key"], SortKey.DOCUMENT.value)) + self.datastore.insert_eps_document_object( + self.internal_id, docs_to_store[0]["key"], document + ) + self.datastore.insert_eps_document_object( + self.internal_id, docs_to_store[1]["key"], document + ) + + # Insert record + prescription_id, nhs_number = self.get_new_record_keys() + record = self.get_record(nhs_number) + + self.datastore.insert_eps_record_object(self.internal_id, prescription_id, record) + + # Update record + record["SCN"] += 1 + object_to_store = {"key": prescription_id, "value": record, "vectorClock": None} + + def throw_data_store_error(*_, is_update=None): + raise EpsDataStoreError( + self.datastore.client, None, EpsDataStoreError.CONDITIONAL_UPDATE_FAILURE + ) + + self.datastore.insert_eps_record_object = Mock(side_effect=throw_data_store_error) + self.datastore.delete_document = Mock() + + with self.assertRaises(EpsSystemError) as cm: + apply_smart_update( + object_to_store, 0, self.internal_id, self.logger, self.datastore, docs_to_store + ) + + self.assertEqual( + cm.exception.error_topic, + EpsSystemError.IMMEDIATE_REQUEUE, + "Expected EpsSystemError with IMMEDIATE_REQUEUE topic not raised", + ) + + # Check that smart update failure is logged + self.assertTrue( + "EPS0126a" in self.logger.called_references, "Expected EPS0126a log entry not found" + ) + + # Check that document deletion is checked + log_keys_b = [ + keys["key"] for (ref, keys) in self.logger.logged_messages if ref == "EPS0126b" + ] + for doc in docs_to_store: + self.assertTrue( + doc["key"] in log_keys_b, + f"Expected EPS0126b log entry for {doc['key']} not found", + ) + + # Check that non-notifications are not deleted + log_keys_d = [ + keys["key"] for (ref, keys) in self.logger.logged_messages if ref == "EPS0126d" + ] + self.assertTrue( + docs_to_store[0]["key"] in log_keys_d, + f"Expected EPS0126d log entry for {docs_to_store[0]['key']} not found", + ) + + # Check that notifications are deleted + log_keys_c = [ + keys["key"] for (ref, keys) in self.logger.logged_messages if ref == "EPS0126c" + ] + self.assertTrue( + docs_to_store[1]["key"] in log_keys_c, + f"Expected EPS0126c log entry for {docs_to_store[1]['key']} not found", + ) + + self.datastore.delete_document.assert_called_once_with( + self.internal_id, documentKey=docs_to_store[1]["key"], deleteNotification=True + ) diff --git a/tests/nhsfundamentals/timeutilities_test.py b/tests/nhsfundamentals/time_utilities_test.py similarity index 93% rename from tests/nhsfundamentals/timeutilities_test.py rename to tests/nhsfundamentals/time_utilities_test.py index 016ec77..f1ce8a4 100644 --- a/tests/nhsfundamentals/timeutilities_test.py +++ b/tests/nhsfundamentals/time_utilities_test.py @@ -4,10 +4,10 @@ from parameterized.parameterized import parameterized -from eps_spine_shared.nhsfundamentals.timeutilities import ( +from eps_spine_shared.nhsfundamentals.time_utilities import ( TimeFormats, - _guess_common_datetime_format, convert_spine_date, + guess_common_datetime_format, time_now_as_string, ) @@ -29,7 +29,7 @@ def test_time_now_as_string(self, _, utc_now, expected): """ Check time_now_as_string returns standard spine format by default matching UTC time. """ - with mock.patch("eps_spine_shared.nhsfundamentals.timeutilities.now") as mock_now: + with mock.patch("eps_spine_shared.nhsfundamentals.time_utilities.now") as mock_now: mock_now.return_value = datetime.strptime(utc_now, "%Y-%m-%d %H:%M:%S") result = time_now_as_string() self.assertEqual(expected, result) @@ -59,14 +59,14 @@ def test_guess_common_datetime_format_default(self, _, time_string, expected): """ Check time format determined from date time string using default settings """ - result = _guess_common_datetime_format(time_string) + result = guess_common_datetime_format(time_string) self.assertEqual(expected, result) def test_guess_common_datetime_format_none_if_unknown(self): """ Check time format determined from date time string specifying to return none if could not be determined """ - result = _guess_common_datetime_format("202", False) + result = guess_common_datetime_format("202", False) self.assertIsNone(result) def test_guess_common_datetime_format_error_if_unknown_format_unknown(self): @@ -74,13 +74,13 @@ def test_guess_common_datetime_format_error_if_unknown_format_unknown(self): Check time format determined from date time string with an unknown format, with raise error true """ with self.assertRaises(ValueError): - _ = _guess_common_datetime_format("202", True) + _ = guess_common_datetime_format("202", True) def test_guess_common_datetime_format_error_if_unknown_format_known(self): """ Check time format determined from date time string with a known format, with raise error true """ - result = _guess_common_datetime_format("2020", True) + result = guess_common_datetime_format("2020", True) self.assertEqual(TimeFormats.STANDARD_DATE_FORMAT_YEAR_ONLY, result) diff --git a/tests/spinecore/base_utilities_test.py b/tests/spinecore/base_utilities_test.py new file mode 100644 index 0000000..1749c3f --- /dev/null +++ b/tests/spinecore/base_utilities_test.py @@ -0,0 +1,62 @@ +from unittest.case import TestCase + +from eps_spine_shared.spinecore.base_utilities import handle_encoding_oddities, quoted + + +class HandleEncodingOdditiesTest(TestCase): + """Test that handle_encoding_oddities handles encoding oddities""" + + def test_basic_ascii(self): + """test that basic ascii is unchanged""" + self.assertEqual(handle_encoding_oddities(b"simple ascii"), "simple ascii") + + def test_basic_unicode(self): + """test that basic unicode (ascii compatible) is unchanged""" + self.assertEqual(handle_encoding_oddities("simple unicode"), "simple unicode") + + def test_invalid_utf8(self): + """test that invalid UTF-8 sequences are replaced with ?""" + self.assertEqual(handle_encoding_oddities(b"valid \xe2sc\xef\xec"), "valid ?sc??") + + def test_invalid_utf8_attempt_replacement(self): + """test that invalid UTF-8 sequences are replaced with characters where possible""" + self.assertEqual(handle_encoding_oddities(b"valid \xe2sc\xef\xec", True), "valid ascii") + + def test_valid_utf8(self): + """test that valid utf-8 has accents stripped""" + self.assertEqual( + handle_encoding_oddities("valid \u00e2sc\u00ef\u00ec".encode("utf8")), "valid ascii" + ) + + def test_valid_utf8_attempt_replacement(self): + """test that the attempt replacement option has no effect for utf8""" + self.assertEqual( + handle_encoding_oddities("valid \u00e2sc\u00ef\u00ec".encode("utf8"), True), + "valid ascii", + ) + + def test_valid_utf8_with_non_spacing_mark(self): + """test that utf8 with a non-spacing mark is also handled""" + self.assertEqual( + handle_encoding_oddities("valid \u00e2sc\u00efi\u0300".encode("utf8")), "valid ascii" + ) + + def test_valid_unicode(self): + """test that a unicode native string is returned with accents removed""" + self.assertEqual(handle_encoding_oddities("valid \u00e2sc\u00ef\u00ec"), "valid ascii") + + def test_not_a_string(self): + """test that non-strings are stringified""" + self.assertEqual(handle_encoding_oddities(123), "123") + + +class QuotedTest(TestCase): + """Test that quoted returns the value as a string surrounded by double quotes""" + + def test_basic_string(self): + """test that a basic string is quoted""" + self.assertEqual(quoted("simple string"), '"simple string"') + + def test_non_string(self): + """test that a non-string is stringified and quoted""" + self.assertEqual(quoted(123), '"123"') diff --git a/tests/validation/common_test.py b/tests/validation/common_test.py new file mode 100644 index 0000000..c954c94 --- /dev/null +++ b/tests/validation/common_test.py @@ -0,0 +1,286 @@ +import unittest +from unittest.mock import MagicMock + +import eps_spine_shared.validation.common as common_validator +from eps_spine_shared.errors import EpsValidationError +from eps_spine_shared.logger import EpsLogger +from eps_spine_shared.testing.mock_logger import MockLogObject +from eps_spine_shared.validation import message_vocab + + +class CommonPrescriptionValidatorTest(unittest.TestCase): + def setUp(self): + self.log_object = EpsLogger(MockLogObject()) + self.internal_id = "test-internal-id" + + self.context = MagicMock() + self.context.msgOutput = {} + self.context.outputFields = set() + + +class TestCheckNominatedPerformer(CommonPrescriptionValidatorTest): + def test_valid_nominated_performer(self): + self.context.msgOutput[message_vocab.NOMPERFORMER] = "VALID123" + self.context.msgOutput[message_vocab.NOMPERFORMER_TYPE] = "P1" + + common_validator.check_nominated_performer(self.context) + + self.assertIn(message_vocab.NOMPERFORMER, self.context.outputFields) + self.assertIn(message_vocab.NOMPERFORMER_TYPE, self.context.outputFields) + + def test_present_but_empty_nominated_performer(self): + self.context.msgOutput[message_vocab.NOMPERFORMER] = "" + + with self.assertRaises(EpsValidationError) as cm: + common_validator.check_nominated_performer(self.context) + + self.assertEqual(str(cm.exception), "nominatedPerformer is present but empty") + + def test_invalid_nominated_performer_format(self): + self.context.msgOutput[message_vocab.NOMPERFORMER] = "invalid_format" + self.context.msgOutput[message_vocab.NOMPERFORMER_TYPE] = "P1" + + with self.assertRaises(EpsValidationError) as cm: + common_validator.check_nominated_performer(self.context) + + self.assertEqual(str(cm.exception), "nominatedPerformer has invalid format") + + def test_invalid_nominated_performer_type(self): + self.context.msgOutput[message_vocab.NOMPERFORMER] = "VALID123" + self.context.msgOutput[message_vocab.NOMPERFORMER_TYPE] = "invalid_type" + + with self.assertRaises(EpsValidationError) as cm: + common_validator.check_nominated_performer(self.context) + + self.assertEqual(str(cm.exception), "nominatedPerformer has invalid type") + + +class TestCheckPrescriptionId(CommonPrescriptionValidatorTest): + def test_valid_prescription_id(self): + self.context.msgOutput[message_vocab.PRESCID] = "7D9625-Z72BF2-11E3AC" + + common_validator.check_prescription_id(self.context, self.internal_id, self.log_object) + + self.assertIn(message_vocab.PRESCID, self.context.outputFields) + + def test_invalid_prescription_id_format(self): + self.context.msgOutput[message_vocab.PRESCID] = "invalid_format" + + with self.assertRaises(EpsValidationError) as cm: + common_validator.check_prescription_id(self.context, self.internal_id, self.log_object) + + self.assertEqual(str(cm.exception), message_vocab.PRESCID + " has invalid format") + + def test_invalid_prescription_id_checksum(self): + self.context.msgOutput[message_vocab.PRESCID] = "7D9625-Z72BF2-11E3AX" + + with self.assertRaises(EpsValidationError) as cm: + common_validator.check_prescription_id(self.context, self.internal_id, self.log_object) + + self.assertEqual(str(cm.exception), message_vocab.PRESCID + " has invalid checksum") + + +class TestCheckOrganisationAndRoles(CommonPrescriptionValidatorTest): + def setUp(self): + super().setUp() + self.context.msgOutput[message_vocab.AGENTORG] = "ORG12345" + self.context.msgOutput[message_vocab.ROLEPROFILE] = "123456789012345" + self.context.msgOutput[message_vocab.ROLE] = "ROLE" + + def test_valid_organisation_and_roles(self): + common_validator.check_organisation_and_roles( + self.context, self.internal_id, self.log_object + ) + + self.assertIn(message_vocab.AGENTORG, self.context.outputFields) + self.assertIn(message_vocab.ROLEPROFILE, self.context.outputFields) + self.assertIn(message_vocab.ROLE, self.context.outputFields) + + def test_invalid_organisation_format(self): + self.context.msgOutput[message_vocab.AGENTORG] = "invalid_org" + + with self.assertRaises(EpsValidationError) as cm: + common_validator.check_organisation_and_roles( + self.context, self.internal_id, self.log_object + ) + + self.assertEqual(str(cm.exception), message_vocab.AGENTORG + " has invalid format") + + def test_invalid_role_profile_format(self): + self.context.msgOutput[message_vocab.ROLEPROFILE] = "invalid_role_profile" + + common_validator.check_organisation_and_roles( + self.context, self.internal_id, self.log_object + ) + + self.assertTrue(self.log_object.logger.was_logged("EPS0323b")) + self.assertIn(message_vocab.AGENTORG, self.context.outputFields) + self.assertIn(message_vocab.ROLEPROFILE, self.context.outputFields) + self.assertIn(message_vocab.ROLE, self.context.outputFields) + + def test_role_not_provided(self): + self.context.msgOutput[message_vocab.ROLE] = "NotProvided" + + common_validator.check_organisation_and_roles( + self.context, self.internal_id, self.log_object + ) + + self.assertTrue(self.log_object.logger.was_logged("EPS0330")) + self.assertIn(message_vocab.AGENTORG, self.context.outputFields) + self.assertIn(message_vocab.ROLEPROFILE, self.context.outputFields) + self.assertIn(message_vocab.ROLE, self.context.outputFields) + + def test_invalid_role_format(self): + self.context.msgOutput[message_vocab.ROLE] = "invalid_role" + + common_validator.check_organisation_and_roles( + self.context, self.internal_id, self.log_object + ) + + self.assertTrue(self.log_object.logger.was_logged("EPS0323")) + self.assertIn(message_vocab.AGENTORG, self.context.outputFields) + self.assertIn(message_vocab.ROLEPROFILE, self.context.outputFields) + self.assertIn(message_vocab.ROLE, self.context.outputFields) + + +class TestCheckNhsNumber(CommonPrescriptionValidatorTest): + def test_valid_nhs_number(self): + self.context.msgOutput[message_vocab.PATIENTID] = "9434765919" + + common_validator.check_nhs_number(self.context) + + self.assertIn(message_vocab.PATIENTID, self.context.outputFields) + + def test_invalid_nhs_number_format(self): + self.context.msgOutput[message_vocab.PATIENTID] = "invalid_format" + + with self.assertRaises(EpsValidationError) as cm: + common_validator.check_nhs_number(self.context) + + self.assertEqual(str(cm.exception), message_vocab.PATIENTID + " is not valid") + + def test_invalid_nhs_number_checksum(self): + self.context.msgOutput[message_vocab.PATIENTID] = "9434765918" + + with self.assertRaises(EpsValidationError) as cm: + common_validator.check_nhs_number(self.context) + + self.assertEqual(str(cm.exception), message_vocab.PATIENTID + " is not valid") + + +class TestCheckStandardDateTime(CommonPrescriptionValidatorTest): + def test_valid_standard_date_time(self): + self.context.msgOutput[message_vocab.CLAIM_DATE] = "20240101120000" + + common_validator.check_standard_date_time( + self.context, message_vocab.CLAIM_DATE, self.internal_id, self.log_object + ) + + self.assertIn(message_vocab.CLAIM_DATE, self.context.outputFields) + + def test_valid_international_standard_date_time(self): + self.context.msgOutput[message_vocab.CLAIM_DATE] = "20240101120000+0100" + + common_validator.check_standard_date_time( + self.context, message_vocab.CLAIM_DATE, self.internal_id, self.log_object + ) + + self.assertIn(message_vocab.CLAIM_DATE, self.context.outputFields) + + def test_invalid_standard_date_time_format(self): + self.context.msgOutput[message_vocab.CLAIM_DATE] = "invalid_format" + + with self.assertRaises(EpsValidationError) as cm: + common_validator.check_standard_date_time( + self.context, message_vocab.CLAIM_DATE, self.internal_id, self.log_object + ) + + self.assertEqual( + str(cm.exception), + message_vocab.CLAIM_DATE + + " is not a valid time or in the valid format; expected format %Y%m%d%H%M%S", + ) + + def test_invalid_international_standard_date_time_format(self): + self.context.msgOutput[message_vocab.CLAIM_DATE] = "20240101120000+0200" + + with self.assertRaises(EpsValidationError) as cm: + common_validator.check_standard_date_time( + self.context, message_vocab.CLAIM_DATE, self.internal_id, self.log_object + ) + + self.assertEqual( + str(cm.exception), + message_vocab.CLAIM_DATE + + " is not a valid time or in the valid format; expected format %Y%m%d%H%M%S", + ) + + +class TestCheckStandardDate(CommonPrescriptionValidatorTest): + def test_valid_standard_date(self): + self.context.msgOutput[message_vocab.CLAIM_DATE] = "20240101" + + common_validator.check_standard_date(self.context, message_vocab.CLAIM_DATE) + + self.assertIn(message_vocab.CLAIM_DATE, self.context.outputFields) + + def test_invalid_standard_date_format(self): + self.context.msgOutput[message_vocab.CLAIM_DATE] = "invalid_format" + + with self.assertRaises(EpsValidationError) as cm: + common_validator.check_standard_date(self.context, message_vocab.CLAIM_DATE) + + self.assertEqual( + str(cm.exception), + message_vocab.CLAIM_DATE + + " is not a valid time or in the valid format; expected format %Y%m%d", + ) + + def test_invalid_standard_date_format_correct_length(self): + self.context.msgOutput[message_vocab.CLAIM_DATE] = "20240132" + + with self.assertRaises(EpsValidationError) as cm: + common_validator.check_standard_date(self.context, message_vocab.CLAIM_DATE) + + self.assertEqual( + str(cm.exception), + message_vocab.CLAIM_DATE + + " is not a valid time or in the valid format; expected format %Y%m%d", + ) + + +class TestCheckHL7EventID(CommonPrescriptionValidatorTest): + def test_valid_hl7_event_id(self): + self.context.msgOutput[message_vocab.HL7EVENTID] = "C0AB090A-FDDC-4B64-97AD-2319A2309C2F" + + common_validator.check_hl7_event_id(self.context) + + self.assertIn(message_vocab.HL7EVENTID, self.context.outputFields) + + def test_invalid_hl7_event_id_format(self): + self.context.msgOutput[message_vocab.HL7EVENTID] = "invalid_format" + + with self.assertRaises(EpsValidationError) as cm: + common_validator.check_hl7_event_id(self.context) + + self.assertEqual(str(cm.exception), message_vocab.HL7EVENTID + " has invalid format") + + +class TestCheckMandatoryItems(CommonPrescriptionValidatorTest): + def test_all_mandatory_items_present(self): + mandatory_items = [message_vocab.PATIENTID, message_vocab.PRESCID] + + for item in mandatory_items: + self.context.msgOutput[item] = "test_value" + + common_validator.check_mandatory_items(self.context, mandatory_items) + + def test_missing_mandatory_item(self): + mandatory_items = [message_vocab.PATIENTID, message_vocab.PRESCID] + + self.context.msgOutput[message_vocab.PATIENTID] = "test" + + with self.assertRaises(EpsValidationError) as cm: + common_validator.check_mandatory_items(self.context, mandatory_items) + + self.assertEqual(str(cm.exception), f"Mandatory field {message_vocab.PRESCID} missing") diff --git a/tests/validation/create_test.py b/tests/validation/create_test.py new file mode 100644 index 0000000..5466f65 --- /dev/null +++ b/tests/validation/create_test.py @@ -0,0 +1,794 @@ +import unittest +from datetime import datetime +from unittest.mock import MagicMock, patch + +from parameterized import parameterized + +import eps_spine_shared.validation.create as create_validator +from eps_spine_shared.errors import EpsValidationError +from eps_spine_shared.logger import EpsLogger +from eps_spine_shared.testing.mock_logger import MockLogObject +from eps_spine_shared.validation import constants, message_vocab + + +class CreatePrescriptionValidatorTest(unittest.TestCase): + def setUp(self): + self.log_object = EpsLogger(MockLogObject()) + self.internal_id = "test-internal-id" + + self.context = MagicMock() + self.context.msgOutput = {} + self.context.outputFields = set() + + +class TestCheckHcplOrg(CreatePrescriptionValidatorTest): + def test_valid_hcpl_org(self): + self.context.msgOutput[message_vocab.HCPLORG] = "ORG12345" + create_validator.check_hcpl_org(self.context) + + def test_invalid_format_raises_error(self): + self.context.msgOutput[message_vocab.HCPLORG] = "ORG@1234" + + with self.assertRaises(EpsValidationError) as cm: + create_validator.check_hcpl_org(self.context) + + self.assertEqual(str(cm.exception), message_vocab.HCPLORG + " has invalid format") + + +class TestCheckSignedTime(CreatePrescriptionValidatorTest): + def test_valid_signed_time(self): + self.context.msgOutput[message_vocab.SIGNED_TIME] = "20260911123456" + create_validator.check_signed_time(self.context, self.internal_id, self.log_object) + + self.assertIn(message_vocab.SIGNED_TIME, self.context.outputFields) + + @parameterized.expand( + [ + ("+0100"), + ("-0000"), + ("+0000"), + ] + ) + def test_valid_international_signed_time(self, date_suffix): + self.context.msgOutput[message_vocab.SIGNED_TIME] = "20260911123456" + date_suffix + create_validator.check_signed_time(self.context, self.internal_id, self.log_object) + + self.assertIn(message_vocab.SIGNED_TIME, self.context.outputFields) + + def test_invalid_international_signed_time_raises_error(self): + self.context.msgOutput[message_vocab.SIGNED_TIME] = "20260911123456+0200" + + with self.assertRaises(EpsValidationError) as cm: + create_validator.check_signed_time(self.context, self.internal_id, self.log_object) + + self.assertEqual( + str(cm.exception), + message_vocab.SIGNED_TIME + + " is not a valid time or in the valid format; expected format %Y%m%d%H%M%S", + ) + + def test_wrong_length_raises_error(self): + self.context.msgOutput[message_vocab.SIGNED_TIME] = "202609111234567" + + with self.assertRaises(EpsValidationError) as cm: + create_validator.check_signed_time(self.context, self.internal_id, self.log_object) + + self.assertEqual( + str(cm.exception), + message_vocab.SIGNED_TIME + + " is not a valid time or in the valid format; expected format %Y%m%d%H%M%S", + ) + + +class TestCheckDaysSupply(CreatePrescriptionValidatorTest): + def test_none(self): + self.context.msgOutput[message_vocab.DAYS_SUPPLY] = None + create_validator.check_days_supply(self.context) + + self.assertIn(message_vocab.DAYS_SUPPLY, self.context.outputFields) + + def test_valid_integer(self): + self.context.msgOutput[message_vocab.DAYS_SUPPLY] = "30" + create_validator.check_days_supply(self.context) + + self.assertIn(message_vocab.DAYS_SUPPLY, self.context.outputFields) + self.assertEqual(self.context.msgOutput[message_vocab.DAYS_SUPPLY], 30) + + def test_non_integer(self): + self.context.msgOutput[message_vocab.DAYS_SUPPLY] = "one" + + with self.assertRaises(EpsValidationError) as cm: + create_validator.check_days_supply(self.context) + + self.assertEqual(str(cm.exception), "daysSupply is not an integer") + + def test_negative_integer(self): + self.context.msgOutput[message_vocab.DAYS_SUPPLY] = "-5" + + with self.assertRaises(EpsValidationError) as cm: + create_validator.check_days_supply(self.context) + + self.assertEqual(str(cm.exception), "daysSupply is not an integer") + + def test_exceeds_max(self): + self.context.msgOutput[message_vocab.DAYS_SUPPLY] = str(constants.MAX_DAYSSUPPLY + 1) + + with self.assertRaises(EpsValidationError) as cm: + create_validator.check_days_supply(self.context) + + self.assertEqual( + str(cm.exception), "daysSupply cannot exceed " + str(constants.MAX_DAYSSUPPLY) + ) + + +class TestCheckRepeatDispenseWindow(CreatePrescriptionValidatorTest): + def setUp(self): + super().setUp() + self.context.msgOutput[message_vocab.TREATMENTTYPE] = constants.STATUS_REPEAT_DISP + self.handle_time = datetime(2026, 9, 11, 12, 34, 56) + + def test_non_repeat(self): + self.context.msgOutput[message_vocab.TREATMENTTYPE] = constants.STATUS_ACUTE + create_validator.check_repeat_dispense_window(self.context, self.handle_time) + + self.assertEqual(self.context.msgOutput[message_vocab.DAYS_SUPPLY_LOW], "20260911") + self.assertEqual(self.context.msgOutput[message_vocab.DAYS_SUPPLY_HIGH], "20270911") + + self.assertIn(message_vocab.DAYS_SUPPLY_LOW, self.context.outputFields) + self.assertIn(message_vocab.DAYS_SUPPLY_HIGH, self.context.outputFields) + + def test_missing_low_and_high(self): + with self.assertRaises(EpsValidationError) as cm: + create_validator.check_repeat_dispense_window(self.context, self.handle_time) + + self.assertEqual( + str(cm.exception), + "daysSupply effective time not provided but prescription treatment type is repeat", + ) + + @parameterized.expand( + [ + ("20260911", "202709111", "daysSupplyValidHigh"), + ("202609111", "20270911", "daysSupplyValidLow"), + ] + ) + def test_invalid_dates(self, low_date, high_date, incorrect_field): + self.context.msgOutput[message_vocab.DAYS_SUPPLY_LOW] = low_date + self.context.msgOutput[message_vocab.DAYS_SUPPLY_HIGH] = high_date + + with self.assertRaises(EpsValidationError) as cm: + create_validator.check_repeat_dispense_window(self.context, self.handle_time) + + self.assertEqual( + str(cm.exception), + f"{incorrect_field} is not a valid time or in the valid format; expected format %Y%m%d", + ) + + def test_high_date_exceeds_limit(self): + self.context.msgOutput[message_vocab.DAYS_SUPPLY_LOW] = "20260911" + self.context.msgOutput[message_vocab.DAYS_SUPPLY_HIGH] = "20280911" + + with self.assertRaises(EpsValidationError) as cm: + create_validator.check_repeat_dispense_window(self.context, self.handle_time) + + self.assertEqual( + str(cm.exception), + f"daysSupplyValidHigh is more than {str(constants.MAX_FUTURESUPPLYMONTHS)} months beyond current day", + ) + + def test_high_date_in_the_past(self): + self.context.msgOutput[message_vocab.DAYS_SUPPLY_LOW] = "20260911" + self.context.msgOutput[message_vocab.DAYS_SUPPLY_HIGH] = "20260910" + + with self.assertRaises(EpsValidationError) as cm: + create_validator.check_repeat_dispense_window(self.context, self.handle_time) + + self.assertEqual(str(cm.exception), "daysSupplyValidHigh is in the past") + + def test_low_after_high(self): + self.context.msgOutput[message_vocab.DAYS_SUPPLY_LOW] = "20260912" + self.context.msgOutput[message_vocab.DAYS_SUPPLY_HIGH] = "20260911" + + with self.assertRaises(EpsValidationError) as cm: + create_validator.check_repeat_dispense_window(self.context, self.handle_time) + + self.assertEqual(str(cm.exception), "daysSupplyValid low is after daysSupplyValidHigh") + + +class TestCheckPrescriberDetails(CreatePrescriptionValidatorTest): + def test_8_char_alphanumeric(self): + self.context.msgOutput[message_vocab.AGENT_PERSON] = "ABCD1234" + create_validator.check_prescriber_details(self.context, self.internal_id, self.log_object) + + self.assertIn(message_vocab.AGENT_PERSON, self.context.outputFields) + self.assertFalse(self.log_object.logger.was_logged("EPS0323a")) + + def test_12_char_alphanumeric(self): + self.context.msgOutput[message_vocab.AGENT_PERSON] = "ABCD12345678" + create_validator.check_prescriber_details(self.context, self.internal_id, self.log_object) + + self.assertIn(message_vocab.AGENT_PERSON, self.context.outputFields) + self.assertTrue(self.log_object.logger.was_logged("EPS0323a")) + + def test_too_long_raises_error(self): + self.context.msgOutput[message_vocab.AGENT_PERSON] = "ABCD123456789" + + with self.assertRaises(EpsValidationError) as cm: + create_validator.check_prescriber_details( + self.context, self.internal_id, self.log_object + ) + + self.assertEqual(str(cm.exception), message_vocab.AGENT_PERSON + " has invalid format") + self.assertTrue( + self.log_object.logger.was_multiple_value_logged( + "EPS0323a", {"internalID": self.internal_id, "prescribingGpCode": "ABCD123456789"} + ) + ) + + def test_special_chars_raises_error(self): + self.context.msgOutput[message_vocab.AGENT_PERSON] = "ABC@1234" + + with self.assertRaises(EpsValidationError) as cm: + create_validator.check_prescriber_details( + self.context, self.internal_id, self.log_object + ) + + self.assertEqual(str(cm.exception), message_vocab.AGENT_PERSON + " has invalid format") + + def test_adds_to_outputFields(self): + self.context.msgOutput[message_vocab.AGENT_PERSON] = "ABCD1234" + create_validator.check_prescriber_details(self.context, self.internal_id, self.log_object) + + self.assertIn(message_vocab.AGENT_PERSON, self.context.outputFields) + + +class TestCheckPatientName(CreatePrescriptionValidatorTest): + def test_adds_to_outputFields(self): + create_validator.check_patient_name(self.context) + + self.assertIn(message_vocab.PREFIX, self.context.outputFields) + self.assertIn(message_vocab.SUFFIX, self.context.outputFields) + self.assertIn(message_vocab.GIVEN, self.context.outputFields) + self.assertIn(message_vocab.FAMILY, self.context.outputFields) + + +class TestCheckPrescriptionTreatmentType(CreatePrescriptionValidatorTest): + def test_unrecognised_treatment_type_raises_error(self): + self.context.msgOutput[message_vocab.TREATMENTTYPE] = "9999" + + with self.assertRaises(EpsValidationError) as cm: + create_validator.check_prescription_treatment_type(self.context) + + self.assertEqual(str(cm.exception), "prescriptionTreatmentType is not of expected type") + + def test_valid_treatment_type(self): + self.context.msgOutput[message_vocab.TREATMENTTYPE] = constants.STATUS_ACUTE + create_validator.check_prescription_treatment_type(self.context) + + self.assertIn(message_vocab.TREATMENTTYPE, self.context.outputFields) + + +class TestCheckPrescriptionType(CreatePrescriptionValidatorTest): + def test_unrecognised_prescription_type(self): + self.context.msgOutput[message_vocab.PRESCTYPE] = "9999" + create_validator.check_prescription_type(self.context, self.internal_id, self.log_object) + + self.assertEqual(self.context.msgOutput[message_vocab.PRESCTYPE], "NotProvided") + self.assertIn(message_vocab.PRESCTYPE, self.context.outputFields) + + +class TestCheckRepeatDispenseInstances(CreatePrescriptionValidatorTest): + def setUp(self): + super().setUp() + self.context.msgOutput[message_vocab.TREATMENTTYPE] = constants.STATUS_REPEAT_DISP + + def test_acute_prescription_without_repeat_values(self): + self.context.msgOutput[message_vocab.TREATMENTTYPE] = constants.STATUS_ACUTE + self.context.msgOutput[message_vocab.REPEATLOW] = None + self.context.msgOutput[message_vocab.REPEATHIGH] = None + + create_validator.check_repeat_dispense_instances( + self.context, self.internal_id, self.log_object + ) + + self.assertNotIn(message_vocab.REPEATLOW, self.context.outputFields) + self.assertNotIn(message_vocab.REPEATHIGH, self.context.outputFields) + + def test_non_acute_without_repeat_values_raises_error(self): + self.context.msgOutput[message_vocab.REPEATLOW] = None + self.context.msgOutput[message_vocab.REPEATHIGH] = None + + with self.assertRaises(EpsValidationError) as cm: + create_validator.check_repeat_dispense_instances( + self.context, self.internal_id, self.log_object + ) + + self.assertIn("must both be provided", str(cm.exception)) + + @parameterized.expand( + [ + ("1", "abc", message_vocab.REPEATHIGH), + ("abc", "1", message_vocab.REPEATLOW), + ] + ) + def test_repeat_high_or_low_not_integer_raises_error( + self, low_value, high_value, incorrect_field + ): + self.context.msgOutput[message_vocab.REPEATLOW] = low_value + self.context.msgOutput[message_vocab.REPEATHIGH] = high_value + + with self.assertRaises(EpsValidationError) as cm: + create_validator.check_repeat_dispense_instances( + self.context, self.internal_id, self.log_object + ) + + self.assertEqual(str(cm.exception), incorrect_field + " is not an integer") + + def test_repeat_low_not_one_raises_error(self): + self.context.msgOutput[message_vocab.REPEATLOW] = "2" + self.context.msgOutput[message_vocab.REPEATHIGH] = "6" + + with self.assertRaises(EpsValidationError) as cm: + create_validator.check_repeat_dispense_instances( + self.context, self.internal_id, self.log_object + ) + + self.assertEqual(str(cm.exception), message_vocab.REPEATLOW + " must be 1") + + def test_repeat_high_exceeds_max_raises_error(self): + self.context.msgOutput[message_vocab.REPEATLOW] = "1" + self.context.msgOutput[message_vocab.REPEATHIGH] = str( + constants.MAX_PRESCRIPTIONREPEATS + 1 + ) + + with self.assertRaises(EpsValidationError) as cm: + create_validator.check_repeat_dispense_instances( + self.context, self.internal_id, self.log_object + ) + + self.assertIn("must not be over configured maximum", str(cm.exception)) + + def test_repeat_low_greater_than_high_raises_error(self): + self.context.msgOutput[message_vocab.REPEATLOW] = "1" + self.context.msgOutput[message_vocab.REPEATHIGH] = "0" + + with self.assertRaises(EpsValidationError) as cm: + create_validator.check_repeat_dispense_instances( + self.context, self.internal_id, self.log_object + ) + + self.assertIn("is greater than", str(cm.exception)) + + def test_repeat_prescription_with_multiple_instances_logs_warning(self): + self.context.msgOutput[message_vocab.TREATMENTTYPE] = constants.STATUS_REPEAT + self.context.msgOutput[message_vocab.REPEATLOW] = "1" + self.context.msgOutput[message_vocab.REPEATHIGH] = "6" + + create_validator.check_repeat_dispense_instances( + self.context, self.internal_id, self.log_object + ) + + self.assertTrue(self.log_object.logger.was_logged("EPS0509")) + self.assertIn(message_vocab.REPEATLOW, self.context.outputFields) + self.assertIn(message_vocab.REPEATHIGH, self.context.outputFields) + + def test_valid_repeat_dispense_instances(self): + self.context.msgOutput[message_vocab.REPEATLOW] = "1" + self.context.msgOutput[message_vocab.REPEATHIGH] = "6" + + create_validator.check_repeat_dispense_instances( + self.context, self.internal_id, self.log_object + ) + + self.assertIn(message_vocab.REPEATLOW, self.context.outputFields) + self.assertIn(message_vocab.REPEATHIGH, self.context.outputFields) + + +class TestCheckBirthDate(CreatePrescriptionValidatorTest): + def setUp(self): + super().setUp() + self.handle_time = datetime(2026, 9, 11, 12, 34, 56) + + def test_valid_birth_date(self): + self.context.msgOutput[message_vocab.BIRTHTIME] = "20000101" + create_validator.check_birth_date(self.context, self.handle_time) + + self.assertIn(message_vocab.BIRTHTIME, self.context.outputFields) + + def test_birth_date_in_future_raises_error(self): + self.context.msgOutput[message_vocab.BIRTHTIME] = "20260912" + + with self.assertRaises(EpsValidationError) as cm: + create_validator.check_birth_date(self.context, self.handle_time) + + self.assertEqual(str(cm.exception), message_vocab.BIRTHTIME + " is in the future") + + def test_invalid_birth_date_format_raises_error(self): + self.context.msgOutput[message_vocab.BIRTHTIME] = "2000010112" + + with self.assertRaises(EpsValidationError) as cm: + create_validator.check_birth_date(self.context, self.handle_time) + + self.assertEqual( + str(cm.exception), + message_vocab.BIRTHTIME + + " is not a valid time or in the valid format; expected format %Y%m%d", + ) + + +class TestValidateLineItems(CreatePrescriptionValidatorTest): + def setUp(self): + super().setUp() + self.line_item_1_id = "12345678-1234-1234-1234-123456789012" + self.context.msgOutput[message_vocab.LINEITEM_PX + "1" + message_vocab.LINEITEM_SX_ID] = ( + self.line_item_1_id + ) + + def test_no_line_items_raises_error(self): + del self.context.msgOutput[message_vocab.LINEITEM_PX + "1" + message_vocab.LINEITEM_SX_ID] + with self.assertRaises(EpsValidationError) as cm: + create_validator.validate_line_items(self.context, self.internal_id, self.log_object) + + self.assertEqual(str(cm.exception), "No valid line items found") + + def test_single_valid_line_item(self): + self.context.msgOutput[message_vocab.TREATMENTTYPE] = constants.STATUS_ACUTE + + create_validator.validate_line_items(self.context, self.internal_id, self.log_object) + + self.assertEqual(len(self.context.msgOutput[message_vocab.LINEITEMS]), 1) + self.assertEqual( + self.context.msgOutput[message_vocab.LINEITEMS][0][message_vocab.LINEITEM_SX_ID], + self.line_item_1_id, + ) + self.assertIn(message_vocab.LINEITEMS, self.context.outputFields) + + def test_multiple_valid_line_items(self): + self.context.msgOutput[message_vocab.LINEITEM_PX + "2" + message_vocab.LINEITEM_SX_ID] = ( + "12345678-1234-1234-1234-123456789013" + ) + self.context.msgOutput[message_vocab.TREATMENTTYPE] = constants.STATUS_ACUTE + + create_validator.validate_line_items(self.context, self.internal_id, self.log_object) + + self.assertEqual(len(self.context.msgOutput[message_vocab.LINEITEMS]), 2) + self.assertIn(message_vocab.LINEITEMS, self.context.outputFields) + + def test_exceeds_max_line_items_raises_error(self): + for i in range(1, constants.MAX_LINEITEMS + 2): + self.context.msgOutput[ + message_vocab.LINEITEM_PX + str(i) + message_vocab.LINEITEM_SX_ID + ] = f"12345678-1234-1234-1234-1234567890{i:02d}" + self.context.msgOutput[message_vocab.TREATMENTTYPE] = constants.STATUS_ACUTE + + with self.assertRaises(EpsValidationError) as cm: + create_validator.validate_line_items(self.context, self.internal_id, self.log_object) + + self.assertIn("over expected max count", str(cm.exception)) + + def test_line_item_with_repeat_values(self): + self.context.msgOutput[ + message_vocab.LINEITEM_PX + "1" + message_vocab.LINEITEM_SX_REPEATHIGH + ] = "6" + self.context.msgOutput[ + message_vocab.LINEITEM_PX + "1" + message_vocab.LINEITEM_SX_REPEATLOW + ] = "1" + self.context.msgOutput[message_vocab.TREATMENTTYPE] = constants.STATUS_REPEAT_DISP + self.context.msgOutput[message_vocab.REPEATHIGH] = 6 + + create_validator.validate_line_items(self.context, self.internal_id, self.log_object) + + line_items = self.context.msgOutput[message_vocab.LINEITEMS] + self.assertEqual(len(line_items), 1) + self.assertEqual(line_items[0][message_vocab.LINEITEM_DT_MAXREPEATS], "6") + self.assertEqual(line_items[0][message_vocab.LINEITEM_DT_CURRINSTANCE], "1") + + def test_prescription_repeat_less_than_line_item_repeat_raises_error(self): + self.context.msgOutput[ + message_vocab.LINEITEM_PX + "1" + message_vocab.LINEITEM_SX_REPEATHIGH + ] = "6" + self.context.msgOutput[ + message_vocab.LINEITEM_PX + "1" + message_vocab.LINEITEM_SX_REPEATLOW + ] = "1" + self.context.msgOutput[message_vocab.TREATMENTTYPE] = constants.STATUS_REPEAT_DISP + self.context.msgOutput[message_vocab.REPEATHIGH] = 3 + + with self.assertRaises(EpsValidationError) as cm: + create_validator.validate_line_items(self.context, self.internal_id, self.log_object) + + self.assertIn("must not be greater than prescriptionRepeatHigh", str(cm.exception)) + + def test_prescription_repeat_greater_than_all_line_item_repeats_raises_error(self): + self.context.msgOutput[message_vocab.LINEITEM_PX + "2" + message_vocab.LINEITEM_SX_ID] = ( + "12345678-1234-1234-1234-123456789013" + ) + self.context.msgOutput[message_vocab.TREATMENTTYPE] = constants.STATUS_ACUTE + + self.context.msgOutput[message_vocab.REPEATHIGH] = 3 + + self.context.msgOutput[ + message_vocab.LINEITEM_PX + "1" + message_vocab.LINEITEM_SX_REPEATHIGH + ] = "1" + self.context.msgOutput[ + message_vocab.LINEITEM_PX + "1" + message_vocab.LINEITEM_SX_REPEATLOW + ] = "1" + + self.context.msgOutput[ + message_vocab.LINEITEM_PX + "2" + message_vocab.LINEITEM_SX_REPEATHIGH + ] = "1" + self.context.msgOutput[ + message_vocab.LINEITEM_PX + "2" + message_vocab.LINEITEM_SX_REPEATLOW + ] = "1" + + with self.assertRaises(EpsValidationError) as cm: + create_validator.validate_line_items(self.context, self.internal_id, self.log_object) + + self.assertIn( + "Prescription repeat count must not be greater than all Line Item repeat counts", + str(cm.exception), + ) + + +class TestValidateLineItem(CreatePrescriptionValidatorTest): + def setUp(self): + super().setUp() + self.line_item_id = "12345678-1234-1234-1234-123456789012" + self.line_item = 1 + self.line_dict = {} + self.context = MagicMock() + self.context.msgOutput = {} + self.context.outputFields = set() + + def test_invalid_line_item_id(self): + self.line_dict[message_vocab.LINEITEM_DT_ID] = "invalid-line-item-id" + + with self.assertRaises(EpsValidationError) as cm: + create_validator.validate_line_item( + self.context, self.line_item, self.line_dict, 1, self.internal_id, self.log_object + ) + + self.assertEqual(str(cm.exception), "invalid-line-item-id is not a valid GUID format") + + def test_missing_items_from_line_dict(self): + self.line_dict[message_vocab.LINEITEM_DT_ID] = self.line_item_id + self.context.msgOutput[message_vocab.TREATMENTTYPE] = constants.STATUS_ACUTE + + max_repeats = create_validator.validate_line_item( + self.context, self.line_item, self.line_dict, 1, self.internal_id, self.log_object + ) + + self.assertEqual(max_repeats, 1) + + def test_repeat_high_not_integer(self): + p = patch( + "eps_spine_shared.validation.create.check_for_invalid_line_item_repeat_combinations", + MagicMock(), + ) + p.start() + + self.line_dict[message_vocab.LINEITEM_DT_ID] = self.line_item_id + self.context.msgOutput[message_vocab.TREATMENTTYPE] = constants.STATUS_REPEAT_DISP + self.line_dict[message_vocab.LINEITEM_DT_MAXREPEATS] = "abc" + + with self.assertRaises(EpsValidationError) as cm: + create_validator.validate_line_item( + self.context, self.line_item, self.line_dict, 1, self.internal_id, self.log_object + ) + + self.assertEqual(str(cm.exception), "repeat.High for line item 1 is not an integer") + p.stop() + + def test_repeat_high_less_than_one(self): + p = patch( + "eps_spine_shared.validation.create.check_for_invalid_line_item_repeat_combinations", + MagicMock(), + ) + p.start() + + self.line_dict[message_vocab.LINEITEM_DT_ID] = self.line_item_id + self.context.msgOutput[message_vocab.TREATMENTTYPE] = constants.STATUS_REPEAT_DISP + self.line_dict[message_vocab.LINEITEM_DT_MAXREPEATS] = "0" + + with self.assertRaises(EpsValidationError) as cm: + create_validator.validate_line_item( + self.context, self.line_item, self.line_dict, 1, self.internal_id, self.log_object + ) + + self.assertEqual(str(cm.exception), "repeat.High for line item 1 must be greater than zero") + p.stop() + + def test_repeat_high_exceeds_prescription_repeat_high(self): + p = patch( + "eps_spine_shared.validation.create.check_for_invalid_line_item_repeat_combinations", + MagicMock(), + ) + p.start() + + self.line_dict[message_vocab.LINEITEM_DT_ID] = self.line_item_id + self.line_dict[message_vocab.LINEITEM_DT_MAXREPEATS] = "6" + + self.context.msgOutput[message_vocab.TREATMENTTYPE] = constants.STATUS_REPEAT_DISP + self.context.msgOutput[message_vocab.REPEATHIGH] = "3" + + with self.assertRaises(EpsValidationError) as cm: + create_validator.validate_line_item( + self.context, self.line_item, self.line_dict, 1, self.internal_id, self.log_object + ) + + self.assertEqual( + str(cm.exception), + "repeat.High of 6 for line item 1 must not be greater than " + "prescriptionRepeatHigh of 3", + ) + p.stop() + + def test_repeat_high_not_1_when_treatment_type_is_repeat(self): + p = patch( + "eps_spine_shared.validation.create.check_for_invalid_line_item_repeat_combinations", + MagicMock(), + ) + p.start() + + self.line_dict[message_vocab.LINEITEM_DT_ID] = self.line_item_id + self.line_dict[message_vocab.LINEITEM_DT_MAXREPEATS] = "3" + self.line_dict[message_vocab.LINEITEM_DT_CURRINSTANCE] = "1" + + self.context.msgOutput[message_vocab.TREATMENTTYPE] = constants.STATUS_REPEAT + self.context.msgOutput[message_vocab.REPEATHIGH] = "3" + + create_validator.validate_line_item( + self.context, self.line_item, self.line_dict, 1, self.internal_id, self.log_object + ) + + self.assertTrue(self.log_object.logger.was_logged("EPS0509")) + p.stop() + + def test_repeat_low_not_integer(self): + p = patch( + "eps_spine_shared.validation.create.check_for_invalid_line_item_repeat_combinations", + MagicMock(), + ) + p.start() + + self.line_dict[message_vocab.LINEITEM_DT_ID] = self.line_item_id + self.line_dict[message_vocab.LINEITEM_DT_MAXREPEATS] = "3" + self.line_dict[message_vocab.LINEITEM_DT_CURRINSTANCE] = "abc" + + self.context.msgOutput[message_vocab.TREATMENTTYPE] = constants.STATUS_ACUTE + self.context.msgOutput[message_vocab.REPEATHIGH] = "3" + self.context.msgOutput[message_vocab.REPEATLOW] = "1" + + with self.assertRaises(EpsValidationError) as cm: + create_validator.validate_line_item( + self.context, self.line_item, self.line_dict, 1, self.internal_id, self.log_object + ) + + self.assertEqual(str(cm.exception), "repeat.Low for line item 1 is not an integer") + p.stop() + + def test_repeat_low_not_1(self): + p = patch( + "eps_spine_shared.validation.create.check_for_invalid_line_item_repeat_combinations", + MagicMock(), + ) + p.start() + + self.line_dict[message_vocab.LINEITEM_DT_ID] = self.line_item_id + self.line_dict[message_vocab.LINEITEM_DT_MAXREPEATS] = "3" + self.line_dict[message_vocab.LINEITEM_DT_CURRINSTANCE] = "2" + + self.context.msgOutput[message_vocab.TREATMENTTYPE] = constants.STATUS_ACUTE + self.context.msgOutput[message_vocab.REPEATHIGH] = "3" + self.context.msgOutput[message_vocab.REPEATLOW] = "1" + + with self.assertRaises(EpsValidationError) as cm: + create_validator.validate_line_item( + self.context, self.line_item, self.line_dict, 1, self.internal_id, self.log_object + ) + + self.assertEqual(str(cm.exception), "repeat.Low for line item 1 is not set to 1") + p.stop() + + +class TestCheckForInvalidLineItemRepeatCombinations(CreatePrescriptionValidatorTest): + def setUp(self): + super().setUp() + self.line_item = 1 + self.line_dict = {message_vocab.LINEITEM_DT_ID: "12345678-1234-1234-1234-123456789012"} + + def test_repeat_dispense_without_repeat_values_raises_error(self): + self.context.msgOutput[message_vocab.TREATMENTTYPE] = constants.STATUS_REPEAT_DISP + + with self.assertRaises(EpsValidationError) as cm: + create_validator.check_for_invalid_line_item_repeat_combinations( + self.context, self.line_dict, self.line_item + ) + + self.assertEqual( + str(cm.exception), + "repeat.High and repeat.Low values must both be provided for lineItem 1 if not acute prescription", + ) + + def test_acute_prescription_with_repeat_values_raises_error(self): + self.context.msgOutput[message_vocab.TREATMENTTYPE] = constants.STATUS_ACUTE + self.line_dict[message_vocab.LINEITEM_DT_MAXREPEATS] = "6" + self.line_dict[message_vocab.LINEITEM_DT_CURRINSTANCE] = "1" + + with self.assertRaises(EpsValidationError) as cm: + create_validator.check_for_invalid_line_item_repeat_combinations( + self.context, self.line_dict, self.line_item + ) + + self.assertEqual( + str(cm.exception), "Line item 1 repeat value provided for non-repeat prescription" + ) + + def test_repeat_dispense_with_only_repeat_high_raises_error(self): + self.context.msgOutput[message_vocab.TREATMENTTYPE] = constants.STATUS_REPEAT_DISP + self.line_dict[message_vocab.LINEITEM_DT_MAXREPEATS] = "6" + + with self.assertRaises(EpsValidationError) as cm: + create_validator.check_for_invalid_line_item_repeat_combinations( + self.context, self.line_dict, self.line_item + ) + + self.assertEqual( + str(cm.exception), "repeat.High provided but not repeat.Low for line item 1" + ) + + def test_repeat_dispense_with_only_repeat_low_raises_error(self): + self.context.msgOutput[message_vocab.TREATMENTTYPE] = constants.STATUS_REPEAT_DISP + self.line_dict[message_vocab.LINEITEM_DT_CURRINSTANCE] = "1" + + with self.assertRaises(EpsValidationError) as cm: + create_validator.check_for_invalid_line_item_repeat_combinations( + self.context, self.line_dict, self.line_item + ) + + self.assertEqual( + str(cm.exception), "repeat.Low provided but not repeat.High for line item 1" + ) + + def test_repeat_dispense_with_valid_repeat_values(self): + self.context.msgOutput[message_vocab.TREATMENTTYPE] = constants.STATUS_REPEAT_DISP + self.context.msgOutput[message_vocab.REPEATHIGH] = "6" + + self.line_dict[message_vocab.LINEITEM_DT_MAXREPEATS] = "6" + self.line_dict[message_vocab.LINEITEM_DT_CURRINSTANCE] = "1" + + create_validator.check_for_invalid_line_item_repeat_combinations( + self.context, self.line_dict, self.line_item + ) + + +class TestRunValidations(CreatePrescriptionValidatorTest): + def setUp(self): + super().setUp() + self.handle_time = datetime(2026, 9, 11, 12, 34, 56) + + def test_validations_happy_path(self): + self.context.msgOutput[message_vocab.AGENT_PERSON] = "ABCD1234" + self.context.msgOutput[message_vocab.AGENTORG] = "ORG12345" + self.context.msgOutput[message_vocab.ROLEPROFILE] = "123456789012345" + self.context.msgOutput[message_vocab.ROLE] = "ROLE" + self.context.msgOutput[message_vocab.PATIENTID] = "9434765919" + self.context.msgOutput[message_vocab.PRESCTIME] = "20240101120000" + self.context.msgOutput[message_vocab.TREATMENTTYPE] = constants.STATUS_ACUTE + self.context.msgOutput[message_vocab.PRESCTYPE] = "0001" + self.context.msgOutput[message_vocab.REPEATLOW] = None + self.context.msgOutput[message_vocab.REPEATHIGH] = 1 + self.context.msgOutput[message_vocab.BIRTHTIME] = "20000101" + self.context.msgOutput[message_vocab.HL7EVENTID] = "C0AB090A-FDDC-4B64-97AD-2319A2309C2F" + self.context.msgOutput[message_vocab.LINEITEM_PX + "1" + message_vocab.LINEITEM_SX_ID] = ( + "12345678-1234-1234-1234-123456789012" + ) + self.context.msgOutput[message_vocab.HCPLORG] = "ORG12345" + self.context.msgOutput[message_vocab.NOMPERFORMER] = "VALID123" + self.context.msgOutput[message_vocab.NOMPERFORMER_TYPE] = "P1" + self.context.msgOutput[message_vocab.PRESCID] = "7D9625-Z72BF2-11E3AC" + self.context.msgOutput[message_vocab.SIGNED_TIME] = "20260911123456" + self.context.msgOutput[message_vocab.DAYS_SUPPLY] = "30" + + create_validator.run_validations( + self.context, self.handle_time, self.internal_id, self.log_object + )