diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 37583fa..059332f 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -28,11 +28,11 @@ "ms-python.flake8", "eamodio.gitlens", "github.vscode-pull-request-github", - "dbaeumer.vscode-eslint", "lfm.vscode-makefile-term", "streetsidesoftware.code-spell-checker", "timonwong.shellcheck", - "github.vscode-github-actions" + "github.vscode-github-actions", + "tamasfe.even-better-toml" ], "settings": { "python.defaultInterpreterPath": "/home/vscode/.asdf/shims/python", diff --git a/src/eps_spine_shared/common/dynamodb_client.py b/src/eps_spine_shared/common/dynamodb_client.py index 5c173fa..88b6203 100644 --- a/src/eps_spine_shared/common/dynamodb_client.py +++ b/src/eps_spine_shared/common/dynamodb_client.py @@ -70,10 +70,10 @@ def __init__( resource_args = {"service_name": SERVICE_NAME, "region_name": REGION_NAME} if aws_endpoint_url: - log_object.write_log("DDB0003", None, {"awsEndpointUrl": aws_endpoint_url}) + self.log_object.write_log("DDB0003", None, {"awsEndpointUrl": aws_endpoint_url}) resource_args["endpoint_url"] = aws_endpoint_url else: - log_object.write_log("DDB0004", None) + self.log_object.write_log("DDB0004", None) self.resource = session.resource(**resource_args) self.table = self.resource.Table(table_name) @@ -82,7 +82,7 @@ def __init__( self.deserialiser = TypeDeserializer() self.serialiser = TypeSerializer() except Exception as ex: - log_object.write_log("DDB0000", sys.exc_info(), {"error": str(ex)}) + self.log_object.write_log("DDB0000", sys.exc_info(), {"error": str(ex)}) raise ex self.log_object.write_log("DDB0001", None, {"tableName": table_name}) diff --git a/src/eps_spine_shared/common/dynamodb_datastore.py b/src/eps_spine_shared/common/dynamodb_datastore.py index 1113f33..71f81ac 100644 --- a/src/eps_spine_shared/common/dynamodb_datastore.py +++ b/src/eps_spine_shared/common/dynamodb_datastore.py @@ -80,29 +80,27 @@ class EpsDynamoDbDataStore: DEFAULT_EXPIRY_DAYS = 56 MAX_NEXT_ACTIVITY_DATE = "99991231" - def __init__( - self, - log_object, - aws_endpoint_url, - table_name, - role_arn=None, - role_session_name=None, - sts_endpoint_url=None, - ): + def __init__(self, log_object, system_config): """ Instantiate the DynamoDB client. """ self.log_object = EpsLogger(log_object) self.client = EpsDynamoDbClient( log_object, - aws_endpoint_url, - table_name, - role_arn, - role_session_name, - sts_endpoint_url, + system_config["ddb aws endpoint url"], + system_config["datastore table name"], + system_config["datastore role arn"], + system_config["process name"], + system_config["sts endpoint url"], ) self.indexes = EpsDynamoDbIndex(log_object, self.client) + def testConnection(self): + """ + Placeholder test connection, returns constant value + """ + return True + def base64_decode_document_content(self, internal_id, document): """ base64 decode document content in order to store as binary type in DynamoDB. @@ -595,7 +593,7 @@ def _fetch_next_sequence_number(self, internal_id, key, max_sequence_number, rea self.client.insert_items(internal_id, [item], is_update, False) break except EpsDataStoreError as e: - if e.errorTopic == EpsDataStoreError.CONDITIONAL_UPDATE_FAILURE and tries < 25: + if e.error_topic == EpsDataStoreError.CONDITIONAL_UPDATE_FAILURE and tries < 25: sequence_number = item[Attribute.SEQUENCE_NUMBER.name] item[Attribute.SEQUENCE_NUMBER.name] = ( sequence_number + 1 if sequence_number < max_sequence_number else 1 @@ -759,13 +757,13 @@ def delete_record(self, internal_id, record_key): @timer def return_pids_due_for_next_activity( - self, _internal_id, next_activity_start, next_activity_end + self, _internal_id, next_activity_start, next_activity_end, shard=None ): """ Returns all the epsRecord keys for prescriptions whose nextActivity is the same as that provided, and whose next activity date is within the date range provided. """ - return self.indexes.query_next_activity_date(next_activity_start, next_activity_end) + return self.indexes.query_next_activity_date(next_activity_start, next_activity_end, shard) @timer def return_prescription_ids_for_nom_pharm(self, _internal_id, nominated_pharmacy_index_term): diff --git a/src/eps_spine_shared/common/dynamodb_index.py b/src/eps_spine_shared/common/dynamodb_index.py index 0ccb070..420dc1a 100644 --- a/src/eps_spine_shared/common/dynamodb_index.py +++ b/src/eps_spine_shared/common/dynamodb_index.py @@ -314,7 +314,7 @@ def query_claim_id(self, claim_id): return [item[Key.PK.name] for item in items] - def query_next_activity_date(self, range_start, range_end): + def query_next_activity_date(self, range_start, range_end, shard=None): """ Yields the epsRecord keys which match the supplied nextActivity and date range for the nextActivity index. @@ -332,6 +332,10 @@ def query_next_activity_date(self, range_start, range_end): if not valid: return [] + if shard or shard == "": + yield from self._query_next_activity_date_shard(next_activity, sk_expression, shard) + return + shards = [None] + list(range(1, NEXT_ACTIVITY_DATE_PARTITIONS + 1)) for shard in shards: @@ -342,7 +346,9 @@ def _query_next_activity_date_shard(self, next_activity, sk_expression, shard): Return a generator for the epsRecord keys which match the supplied nextActivity and date range for a given pk shard. """ - expected_next_activity = next_activity if shard is None else f"{next_activity}.{shard}" + expected_next_activity = ( + next_activity if shard is None or shard == "" else f"{next_activity}.{shard}" + ) pk_expression = BotoKey(Attribute.NEXT_ACTIVITY.name).eq(expected_next_activity) return self.client.query_index_yield( diff --git a/src/eps_spine_shared/common/prescription/line_item.py b/src/eps_spine_shared/common/prescription/line_item.py index 0fe052f..11aea76 100644 --- a/src/eps_spine_shared/common/prescription/line_item.py +++ b/src/eps_spine_shared/common/prescription/line_item.py @@ -89,7 +89,7 @@ def expire(self, parent_prescription): if current_status not in LineItemStatus.EXPIRY_IMMUTABLE_STATES: new_status = LineItemStatus.EXPIRY_LOOKUP[current_status] self.update_status(new_status) - parent_prescription.logObject.write_log( + parent_prescription.log_object.write_log( "EPS0072b", None, { diff --git a/src/eps_spine_shared/common/prescription/record.py b/src/eps_spine_shared/common/prescription/record.py index 02637f4..2b086c2 100644 --- a/src/eps_spine_shared/common/prescription/record.py +++ b/src/eps_spine_shared/common/prescription/record.py @@ -404,7 +404,7 @@ def _handle_missing_issue(self, issue_number): {"internalID": self.internal_id, "prescriptionID": self.id, "issue": issue_number}, ) # Re-raise this as SpineBusinessError with equivalent errorCode from ErrorBase1722. - raise EpsBusinessError(EpsErrorBase.PRESCRIPTION_NOT_FOUND) + raise EpsBusinessError(EpsErrorBase.MISSING_ISSUE) @property def id(self): @@ -2621,7 +2621,7 @@ def release_next_instance( next_issue_number_str = self._find_next_future_issue_number(current_issue_number_str) if next_issue_number_str is None: # give up if there is no next issue - self.pendingInstanceChange = None + self.pending_instance_change = None return # update the issue @@ -2677,7 +2677,7 @@ def release_next_instance( ) # mark so that we know to update the prescription's current issue number - self.pendingInstanceChange = next_issue_number_str + self.pending_instance_change = next_issue_number_str def add_release_document_ref(self, rel_req_document_ref): """ @@ -2969,7 +2969,7 @@ def fetch_release_response_parameters(self): for line_item in self.current_issue.line_items: line_item_ref = "lineItem" + str(line_item.order) item_status = ( - line_item.previousStatus + line_item.previous_status if line_item.status == LineItemStatus.WITH_DISPENSER else line_item.status ) @@ -3044,6 +3044,7 @@ def force_current_instance_increment(self): new_current_issue_number = False for i in range(self.current_issue_number, self.max_repeats + 1): try: + self.prescription_record[fields.FIELD_INSTANCES][str(i)] new_current_issue_number = i break except KeyError: diff --git a/src/eps_spine_shared/errors.py b/src/eps_spine_shared/errors.py index f9825ef..c650be1 100644 --- a/src/eps_spine_shared/errors.py +++ b/src/eps_spine_shared/errors.py @@ -2,69 +2,135 @@ from botocore.exceptions import NoCredentialsError +# Try to import spine error classes. If successful, we are on spine and should use wrapper classes. +on_spine = False +try: + # from spinecore.prescriptions.common.errors.errorbaseprescriptionsearch \ + # import ErrorBasePrescSearch # pyright: ignore[reportMissingImports] + from spinecore.common.aws.awscommon import ( # pyright: ignore[reportMissingImports] + NoCredentialsErrorWithRetry, + ) + from spinecore.common.errors import ( # pyright: ignore[reportMissingImports] + SpineBusinessError, + SpineSystemError, + ) -class EpsNoCredentialsErrorWithRetry(NoCredentialsError): - """ - Extends NoCredentialsError to provide information about retry attempts. - To be caught in Spine application code and re-raised as NoCredentialsErrorWithRetry. - """ - - fmt = "Unable to locate credentials after {attempts} attempts" - - -class EpsSystemError(Exception): - """ - Exception to be raised if an unexpected system error occurs. - To be caught in Spine application code and re-raised as SpineSystemError. - """ - - MESSAGE_FAILURE = "messageFailure" - DEVELOPMENT_FAILURE = "developmentFailure" - SYSTEM_FAILURE = "systemFailure" - IMMEDIATE_REQUEUE = "immediateRequeue" - RETRY_EXPIRED = "retryExpired" - PUBLISHER_HANDLES_REQUEUE = "publisherHandlesRequeue" - UNRELIABLE_MESSAGE = "unreliableMessage" - - def __init__(self, errorTopic, *args): # noqa: B042 - """ - errorTopic is the topic to be used when writing the WDO to the error exchange - """ - super(EpsSystemError, self).__init__(*args) - self.errorTopic = errorTopic - - -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. - To be caught in Spine application code and re-raised as SpineBusinessError. - """ - - def __init__(self, errorCode, suppInfo=None, messageId=None): # noqa: B042 - super(EpsBusinessError, self).__init__() - self.errorCode = errorCode - self.supplementaryInformation = suppInfo - self.messageId = messageId - - def __str__(self): - if self.supplementaryInformation: - return "{} {}".format(self.errorCode, self.supplementaryInformation) - return str(self.errorCode) - - -class EpsErrorBase(Enum): - """ - To be used in Spine application code to remap to ErrorBases. - """ - - 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 + # from spinecore.prescriptions.common.errors.errorbase1634 \ + # import ErrorBase1634 # pyright: ignore[reportMissingImports] + from spinecore.prescriptions.common.errors.errorbase1719 import ( # pyright: ignore[reportMissingImports] + ErrorBase1719, + ) + from spinecore.prescriptions.common.errors.errorbase1722 import ( # pyright: ignore[reportMissingImports] + ErrorBase1722, + ) + + on_spine = True +except ImportError: + pass + + +if on_spine: + + class EpsNoCredentialsErrorWithRetry: + """ + Wrapper for NoCredentialsErrorWithRetry + """ + + def __init__(self, *args): + raise NoCredentialsErrorWithRetry(*args) + + class EpsSystemError: + """ + Wrapper for SpineSystemError + """ + + 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 + INVALID_LINE_STATE_TRANSITION = ErrorBase1722.INVALID_LINE_STATE_TRANSITION + MAX_REPEAT_MISMATCH = ErrorBase1722.MAX_REPEAT_MISMATCH + NOT_CANCELLED_EXPIRED = ErrorBase1719.NOT_CANCELLED_EXPIRED + NOT_CANCELLED_CANCELLED = ErrorBase1719.NOT_CANCELLED_CANCELLED + NOT_CANCELLED_NOT_DISPENSED = ErrorBase1719.NOT_CANCELLED_NOT_DISPENSED + NOT_CANCELLED_DISPENSED = ErrorBase1719.NOT_CANCELLED_DISPENSED + 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 + +else: + + class EpsNoCredentialsErrorWithRetry(NoCredentialsError): + """ + Extends NoCredentialsError to provide information about retry attempts. + """ + + fmt = "Unable to locate credentials after {attempts} attempts" + + class EpsSystemError(Exception): + """ + Exception to be raised if an unexpected system error occurs. + """ + + MESSAGE_FAILURE = "messageFailure" + DEVELOPMENT_FAILURE = "developmentFailure" + SYSTEM_FAILURE = "systemFailure" + IMMEDIATE_REQUEUE = "immediateRequeue" + RETRY_EXPIRED = "retryExpired" + PUBLISHER_HANDLES_REQUEUE = "publisherHandlesRequeue" + UNRELIABLE_MESSAGE = "unreliableMessage" + + def __init__(self, error_topic, *args): # noqa: B042 + """ + error_topic is the topic to be used when writing the WDO to the error exchange + """ + super(EpsSystemError, self).__init__(*args) + self.error_topic = error_topic + + 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 + super(EpsBusinessError, self).__init__() + self.error_code = error_code + self.supplementary_information = supp_info + self.message_id = message_id + + def __str__(self): + if self.supplementary_information: + return "{} {}".format(self.error_code, self.supplementary_information) + return str(self.error_code) + + class EpsErrorBase(Enum): + """ + To be used in Spine application code to remap to ErrorBases. + """ + + 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 diff --git a/tests/mock_logger.py b/src/eps_spine_shared/testing/mock_logger.py similarity index 100% rename from tests/mock_logger.py rename to src/eps_spine_shared/testing/mock_logger.py diff --git a/tests/common/dynamodb_datastore_test.py b/tests/common/dynamodb_datastore_test.py index a624718..e8585f3 100644 --- a/tests/common/dynamodb_datastore_test.py +++ b/tests/common/dynamodb_datastore_test.py @@ -25,8 +25,8 @@ 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.testing.mock_logger import MockLogObject from tests.dynamodb_test import DynamoDbTest -from tests.mock_logger import MockLogObject class EpsDynamoDbDataStoreTest(DynamoDbTest): @@ -764,7 +764,7 @@ def insert_record(datastore: EpsDynamoDbDataStore, insert_args): logger = MockLogObject() loggers.append(logger) - datastore = EpsDynamoDbDataStore(logger, None, "spine-eps-datastore") + datastore = EpsDynamoDbDataStore(logger, self.system_config) process = Thread( target=insert_record, args=(datastore, (self.internal_id, prescription_id, record)) @@ -829,7 +829,7 @@ def change_record(datastore, change_args): logger = MockLogObject() loggers.append(logger) - datastore = EpsDynamoDbDataStore(logger, None, "spine-eps-datastore") + datastore = EpsDynamoDbDataStore(logger, self.system_config) index = None record_type = None diff --git a/tests/common/indexes_test.py b/tests/common/indexes_test.py index 9982101..31a1bcf 100644 --- a/tests/common/indexes_test.py +++ b/tests/common/indexes_test.py @@ -11,7 +11,7 @@ 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 tests.mock_logger import MockLogObject +from eps_spine_shared.testing.mock_logger import MockLogObject def get_nad_references(): diff --git a/tests/common/prescription/record_test.py b/tests/common/prescription/record_test.py index 436bd38..1f0a53d 100644 --- a/tests/common/prescription/record_test.py +++ b/tests/common/prescription/record_test.py @@ -12,7 +12,7 @@ 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 tests.mock_logger import MockLogObject +from eps_spine_shared.testing.mock_logger import MockLogObject def load_test_example_json(mock_log_object, filename): diff --git a/tests/dynamodb_test.py b/tests/dynamodb_test.py index b12a679..304a53b 100644 --- a/tests/dynamodb_test.py +++ b/tests/dynamodb_test.py @@ -13,7 +13,7 @@ from eps_spine_shared.common.dynamodb_common import SortKey from eps_spine_shared.common.dynamodb_datastore import EpsDynamoDbDataStore from eps_spine_shared.common.prescription.record import PrescriptionStatus -from tests.mock_logger import MockLogObject +from eps_spine_shared.testing.mock_logger import MockLogObject PRESC_ORG = "X26" DISP_ORG = "X27" @@ -176,9 +176,14 @@ def setUp(self) -> None: self.logger: MockLogObject = MockLogObject() - self.datastore: EpsDynamoDbDataStore = EpsDynamoDbDataStore( - self.logger, None, "spine-eps-datastore" - ) + self.system_config = { + "ddb aws endpoint url": "", + "datastore table name": "spine-eps-datastore", + "datastore role arn": "arn:aws:iam::123456789012:role/DynamoDBRole", + "process name": "test-process", + "sts endpoint url": "", + } + self.datastore: EpsDynamoDbDataStore = EpsDynamoDbDataStore(self.logger, self.system_config) self.keys = [] self.internal_id = str(uuid4())