From ad2fc9f13a8b8716340274a8c6bdec61bff0caed Mon Sep 17 00:00:00 2001 From: Mike Metcalfe Date: Sun, 9 Apr 2023 23:01:07 +0200 Subject: [PATCH 1/8] Fixed API tests --- src/senaite/lis2a/adapters/__init__.py | 2 +- src/senaite/lis2a/api/__init__.py | 4 ---- src/senaite/lis2a/interpreter/__init__.py | 3 +-- src/senaite/lis2a/tests/base.py | 2 +- src/senaite/lis2a/tests/doctests/API.rst | 5 +++-- 5 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/senaite/lis2a/adapters/__init__.py b/src/senaite/lis2a/adapters/__init__.py index fd5f788..40e923c 100644 --- a/src/senaite/lis2a/adapters/__init__.py +++ b/src/senaite/lis2a/adapters/__init__.py @@ -32,7 +32,7 @@ from senaite.queue.interfaces import IQueuedTaskAdapter from senaite.queue import api as queueapi from senaite.queue.queue import get_chunks_for -except: +except Exception: IQueuedTaskAdapter = Interface queueapi = None diff --git a/src/senaite/lis2a/api/__init__.py b/src/senaite/lis2a/api/__init__.py index 27bbc3f..efcc69e 100644 --- a/src/senaite/lis2a/api/__init__.py +++ b/src/senaite/lis2a/api/__init__.py @@ -22,17 +22,13 @@ from os.path import splitext from os.path import isfile -from os.path import join import analysis as anapi import json import message as msgapi import six from bika.lims import api -from pkg_resources import resource_filename -from pkg_resources import resource_listdir from plone.resource.utils import iterDirectoriesOfType -from senaite.lis2a import PRODUCT_NAME from senaite.lis2a.interpreter import Interpreter from senaite.lis2a.interpreter import lis2a2 diff --git a/src/senaite/lis2a/interpreter/__init__.py b/src/senaite/lis2a/interpreter/__init__.py index cafe6e3..98b5e79 100644 --- a/src/senaite/lis2a/interpreter/__init__.py +++ b/src/senaite/lis2a/interpreter/__init__.py @@ -22,7 +22,6 @@ import itertools from datetime import datetime -import lis2a2 import six from senaite.lis2a.api import message as msgapi @@ -402,7 +401,7 @@ def to_date(self, ansi_str, default=_marker): try: return datetime.strptime(ansi_str, date_format) - except: + except Exception: if default is _marker: raise ValueError("No ANSI format date") return default diff --git a/src/senaite/lis2a/tests/base.py b/src/senaite/lis2a/tests/base.py index db93327..b53d9d3 100644 --- a/src/senaite/lis2a/tests/base.py +++ b/src/senaite/lis2a/tests/base.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- import unittest2 as unittest -from bika.lims.testing import BASE_TESTING from plone.app.testing import applyProfile from plone.app.testing import FunctionalTesting from plone.app.testing import PLONE_FIXTURE @@ -12,6 +11,7 @@ from plone.app.testing import TEST_USER_PASSWORD from plone.testing import z2 from plone.testing.z2 import Browser +from senaite.core.tests.layers import BASE_TESTING from senaite.lis2a import PRODUCT_NAME diff --git a/src/senaite/lis2a/tests/doctests/API.rst b/src/senaite/lis2a/tests/doctests/API.rst index 81d131d..3c5a44f 100644 --- a/src/senaite/lis2a/tests/doctests/API.rst +++ b/src/senaite/lis2a/tests/doctests/API.rst @@ -5,7 +5,7 @@ LIS2A API Running this test from the buildout directory: - bin/test test_textual_doctests -t API + bin/test test_textual_doctests -m senaite.lis2a -t API Test Setup ~~~~~~~~~~ @@ -124,7 +124,8 @@ Extracting results from a message We can directly extract the results from a message: >>> message = utils.read_file("example_lis2a2_01.txt") - >>> results = api.extract_results(message) + >>> interpreter = api.get_interpreter_for(message) + >>> results = api.extract_results(message, interpreter) And we get one result for each (R)esult record, with the rest of result records as interim fields: From 8167807823b9fc7dd95a15a3579c8faa4678ed3d Mon Sep 17 00:00:00 2001 From: Mike Metcalfe Date: Mon, 10 Apr 2023 10:59:13 +0200 Subject: [PATCH 2/8] Reworked test setup according to senaite.core testing --- src/senaite/lis2a/tests/base.py | 20 +++++++++---------- .../lis2a/tests/doctests/PushConsumer.rst | 17 ++++++++++------ src/senaite/lis2a/tests/test_setup.py | 3 ++- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/src/senaite/lis2a/tests/base.py b/src/senaite/lis2a/tests/base.py index b53d9d3..2b5a01a 100644 --- a/src/senaite/lis2a/tests/base.py +++ b/src/senaite/lis2a/tests/base.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- +import transaction import unittest2 as unittest from plone.app.testing import applyProfile from plone.app.testing import FunctionalTesting @@ -9,8 +10,7 @@ from plone.app.testing import TEST_USER_ID from plone.app.testing import TEST_USER_NAME from plone.app.testing import TEST_USER_PASSWORD -from plone.testing import z2 -from plone.testing.z2 import Browser +from plone.testing import zope from senaite.core.tests.layers import BASE_TESTING from senaite.lis2a import PRODUCT_NAME @@ -24,29 +24,27 @@ def setUpZope(self, app, configurationContext): super(SimpleTestLayer, self).setUpZope(app, configurationContext) # Load ZCML - import bika.lims import senaite.jsonapi import senaite.lis2a - self.loadZCML(package=bika.lims) + self.loadZCML(package=senaite.core) self.loadZCML(package=senaite.jsonapi) self.loadZCML(package=senaite.lims) self.loadZCML(package=senaite.lis2a) # Install product and call its initialize() function - z2.installProduct(app, "bika.lims") - z2.installProduct(app, "senaite.jsonapi") - z2.installProduct(app, "senaite.lims") - z2.installProduct(app, PRODUCT_NAME) + zope.installProduct(app, "senaite.core") + zope.installProduct(app, "senaite.jsonapi") + zope.installProduct(app, "senaite.lims") + zope.installProduct(app, PRODUCT_NAME) def setUpPloneSite(self, portal): super(SimpleTestLayer, self).setUpPloneSite(portal) # Apply Setup Profile (portal_quickinstaller) - applyProfile(portal, 'bika.lims:default') applyProfile(portal, 'senaite.lims:default') applyProfile(portal, '{}:default'.format(PRODUCT_NAME)) - + transaction.commit() ### # Use for simple tests (w/o contents) @@ -76,7 +74,7 @@ def getBrowser(self, loggedIn=True): # Instantiate and return a testbrowser for convenience - browser = Browser(self.portal) + browser = zope.Browser(self.portal) browser.addHeader('Accept-Language', 'en-US') browser.handleErrors = False if loggedIn: diff --git a/src/senaite/lis2a/tests/doctests/PushConsumer.rst b/src/senaite/lis2a/tests/doctests/PushConsumer.rst index 2c5c70c..e835910 100644 --- a/src/senaite/lis2a/tests/doctests/PushConsumer.rst +++ b/src/senaite/lis2a/tests/doctests/PushConsumer.rst @@ -7,7 +7,7 @@ from serial devices to this endpoint each time a transfer phase is completed. Running this test from the buildout directory: - bin/test test_textual_doctests -t PushConsumer + bin/test test_textual_doctests -m senaite.lis2a -t PushConsumer Test Setup ~~~~~~~~~~ @@ -23,8 +23,10 @@ Needed imports: >>> from plone.app.testing import setRoles >>> from plone.app.testing import TEST_USER_ID >>> from senaite.lis2a import api + >>> from senaite.jsonapi.interfaces import IPushConsumer >>> from senaite.lis2a.interpreter import Interpreter >>> from senaite.lis2a.tests import utils + >>> from zope.component import queryAdapter Variables: @@ -49,9 +51,8 @@ Create some basic objects for the test: >>> transaction.commit() -Send messages via push -~~~~~~~~~~~~~~~~~~~~~~~ - +Ensure adapter is available +~~~~~~~~~~~~~~~~~~~~~~~~~~~ >>> messages = [ ... utils.read_file("example_lis2a2_01.txt"), ... utils.read_file("example_lis2a2_02.txt"), @@ -60,8 +61,12 @@ Send messages via push ... "consumer": "senaite.lis2a.import", ... "messages": messages, ... } - >>> post("push", payload) - '..."success": true...' + >>> consumer = queryAdapter(payload, IPushConsumer, name="senaite.lis2a.import") + >>> consumer is not None + True + +Send messages via push +~~~~~~~~~~~~~~~~~~~~~~~ Now, try to send messages with valid sample ids and keywords. Create two samples and receive them first: diff --git a/src/senaite/lis2a/tests/test_setup.py b/src/senaite/lis2a/tests/test_setup.py index a4de32e..445e26b 100644 --- a/src/senaite/lis2a/tests/test_setup.py +++ b/src/senaite/lis2a/tests/test_setup.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- +from Products.CMFPlone.utils import get_installer from senaite.lis2a import PRODUCT_NAME from senaite.lis2a.tests.base import SimpleTestCase @@ -9,7 +10,7 @@ class TestSetup(SimpleTestCase): """ def test_is_senaite_lis2a_installed(self): - qi = self.portal.portal_quickinstaller + qi = get_installer(self.portal) self.assertTrue(qi.isProductInstalled(PRODUCT_NAME)) From d9a10e581dd8e64cd4a35d58a4a728852ace01be Mon Sep 17 00:00:00 2001 From: Mike Metcalfe Date: Mon, 10 Apr 2023 20:17:24 +0200 Subject: [PATCH 3/8] Returned part of test that was deleted by mistake --- src/senaite/lis2a/tests/doctests/PushConsumer.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/senaite/lis2a/tests/doctests/PushConsumer.rst b/src/senaite/lis2a/tests/doctests/PushConsumer.rst index e835910..de6e2d9 100644 --- a/src/senaite/lis2a/tests/doctests/PushConsumer.rst +++ b/src/senaite/lis2a/tests/doctests/PushConsumer.rst @@ -64,6 +64,8 @@ Ensure adapter is available >>> consumer = queryAdapter(payload, IPushConsumer, name="senaite.lis2a.import") >>> consumer is not None True + >>> post("push", payload) + '..."success": true...' Send messages via push ~~~~~~~~~~~~~~~~~~~~~~~ From 24f08f498a31b47864cb7af95d17b02d438b9d6b Mon Sep 17 00:00:00 2001 From: Mike Metcalfe Date: Mon, 10 Apr 2023 20:37:59 +0200 Subject: [PATCH 4/8] Started the introduction of LIS2 queries --- src/senaite/lis2a/api/__init__.py | 11 +++ src/senaite/lis2a/interpreter/__init__.py | 103 +++++++++++++++++++++- src/senaite/lis2a/interpreter/lis2a2.py | 85 ++++++++++++++++-- src/senaite/lis2a/tests/doctests/API.rst | 25 +++++- 4 files changed, 217 insertions(+), 7 deletions(-) diff --git a/src/senaite/lis2a/api/__init__.py b/src/senaite/lis2a/api/__init__.py index efcc69e..edfee43 100644 --- a/src/senaite/lis2a/api/__init__.py +++ b/src/senaite/lis2a/api/__init__.py @@ -196,3 +196,14 @@ def get_interpreter_from_json(str_or_file): with open(str_or_file, "r") as f: str_or_file = f.read() return Interpreter(json.loads(str_or_file)) + + +def extract_queries(message, interpreter): + """Returns a list of query dicts. A given message can contain multiple + queries, so it returns a list of dicts, and each dict + represents a query + """ + interpreter.read(message) + query_data = interpreter.get_queries_data() + interpreter.close() + return query_data diff --git a/src/senaite/lis2a/interpreter/__init__.py b/src/senaite/lis2a/interpreter/__init__.py index 98b5e79..e768145 100644 --- a/src/senaite/lis2a/interpreter/__init__.py +++ b/src/senaite/lis2a/interpreter/__init__.py @@ -119,6 +119,13 @@ def get_message_values(self, key): def get_record_value(self, key, record): """Returns the value for the key passed-in from the record, if any """ + # Get the key of the record to look at + record_type = self.get_record_type(key) + + # Ensure the record type if the key matches the record type + if not record.startswith(record_type): + return + delimiters = { "field_delimiter": self.field_delimiter, "component_delimiter": self.component_delimiter, @@ -206,7 +213,8 @@ def check_result_criteria(self, record): for key, expected_value in self.result_criteria.items(): if self.get_record_type(key) != "R": - raise ValueError("Records other than Result are not supported") + # Records other than Result are not supported here + continue # Get the real value from the record value = self.get_record_value(key, record) @@ -405,3 +413,96 @@ def to_date(self, ansi_str, default=_marker): if default is _marker: raise ValueError("No ANSI format date") return default + + def find_query_records(self): + """Return the query records that match with the result_criteria + """ + query_records = msgapi.get_records(self.message, "Q") + return filter(self.check_query_criteria, query_records) + + def get_queries_data(self): + """Returns a list of dicts containing the query data information to + store from the message passed-in based on the configuration set for this + interpreter. + """ + # Extract the query records that match with the result_criteria + query_records = self.find_query_records() + if not query_records: + return {} + + querys_data = [] + for record in query_records: + + # Generate the query data dict + data = self.to_query_data(record) + if data: + # Append to the list of querys data + querys_data.append(data) + + return querys_data + + def check_query_criteria(self, record): + """Returns whether the record passed in matches with the query + criteria specified by this interpreter + """ + if not self.result_criteria: + raise ValueError("No query criteria set") + + # The interpreter can handle the record if all criteria are met + for key, expected_value in self.result_criteria.items(): + + if self.get_record_type(key) != "Q": + # Records other than Result are not supported here + continue + + # Get the real value from the record + value = self.get_record_value(key, record) + + # Check if value matches with the expected value + if not self.match(value, expected_value): + return False + + return True + + def get_sequnce_number(self, record): + sequence_num = self.get_mapped_values("id", record) + if sequence_num: + sequence_num = sequence_num[0] + return sequence_num + + def get_patient_id(self, record): + patient_id = self.get_mapped_values("patient_id", record) + if patient_id: + patient_id = patient_id[0] + return patient_id + + def get_specimen_id(self, record): + specimen_id = self.get_mapped_values("specimen_id", record) + if specimen_id: + specimen_id = specimen_id[0] + return specimen_id + + def to_query_data(self, record): + """Returns a dict representing the query data information + """ + + def resolve_query_mappings(record): + sequence_num = self.get_sequnce_number(record) + patient_id = self.get_patient_id(record) + specimen_id = self.get_specimen_id(record) + return { + "id": sequence_num, + "patient_id": patient_id, + "specimen_id": specimen_id, + } + + # Resolve mappings for result + data = resolve_query_mappings(record) + + # id and keyword are required + if not all([data["id"]]) and not any([data["patient_id"], data["specimen_id"]]): + return {} + + # Inject additional options (e.g. remove_interims) + data.update(self.get("options", {})) + return data diff --git a/src/senaite/lis2a/interpreter/lis2a2.py b/src/senaite/lis2a/interpreter/lis2a2.py index f78197e..40ce526 100644 --- a/src/senaite/lis2a/interpreter/lis2a2.py +++ b/src/senaite/lis2a/interpreter/lis2a2.py @@ -32,6 +32,7 @@ # Criteria used by the interpreter to determine the results to consider "result_criteria": { "R.ResultStatus": ["", "C", "P", "F", "R", "N"], + "Q.QueryStatus": ["", "O"], }, # Mappings between result_data dict fields and message fields. Used by the @@ -40,7 +41,8 @@ "mappings": { "id": [ "O.SpecimenID", - "O.InstrumentSpecimenID" + "O.InstrumentSpecimenID", + "Q.SequenceNumber", ], "keyword": [ "R.UniversalTestID", @@ -52,7 +54,9 @@ "captured_by": [ "R.OperatorIdentification", "R.VerifierIdentification" - ] + ], + "patient_id": "Q.StartingRangePatientID", + "specimen_id": "Q.StartingRangeSpecimenID", }, # Header Record. See LIS2-A2 Section 6 @@ -496,14 +500,85 @@ # TODO Comment Record. See section 10 "C": {}, - # TODO Request Information Record. See section 11 - "Q": {}, + # Request Information Record. See section 11 + "Q": { + # See Section 5.6.7 + # 11.2 Sequence Number + # This is a required field used in record types that may occur multiple times within a single message. The + # number used defines the i'th occurrence of the associated record type at a particular hierarchical level and + # is reset to one whenever a record of a greater hierarchical significance (lower number) is transmitted or if + # the same record is used at a different hierarchical level (e.g., comment records). + "SequenceNumber": 1, + # 11.3 Starting Range ID Number + # This field may contain three or more components to define a range of patients/specimens/manufacturers + # selection criteria. The first component is the information system patient ID number. The second + # component is the information system specimen ID number. Any further components are manufacturer- + # defined and for use in request subresult information. These components are position dependent. A list of sample IDs + # could be requested by the use of the repeat delimiter to separate IDs. + "StartingRangePatientID": (2, 0), + "StartingRangeSpecimenID": (2, 1), + "StartingRangeManufacturerSpecific": (2, 2), + # 11.4 Ending Range ID Number + # This field is similar to that described in Section 11.3. If a single result or specimen demographic or test + # order is being requested, then this field may be left blank. + "EndingRangeIDNumber": 3, + # 11.5 Universal Test ID + # This field is as described in Section 5.6.1. This field may alternatively contain multiple codes separated + # by repeat delimiters, or the field may contain the text ALL, which signifies a request for all results on all + # tests or batteries for the patients/specimens/tests defined in Sections 11.3 and 11.4 and within the dates + # described in Sections 11.6 and 11.7. + "UniversalTestID": 4, + # 11.6 Nature of Request Time Limits + # Specify whether the date and time limits specified in Sections 11.7 and 11.8 refer to the specimen collect + # or ordered date (see Section 8.4.8) or test date (see Section 8.4.23): S indicates the specimen collect date; + # R indicates the result test date. If nothing is entered, the date criteria are assumed to be the result test date. + "NatureOfRequestTimeLimits": 5, + # 11.7 Beginning Request Results Date and Time + # This field shall represent either a beginning (oldest) date and time for which results are being requested or + # a single date and time. The field may contain a single date and time or multiple dates and times separated + # by repeat delimiters. Each date and time shall be represented as specified in Section 5.6.2. + # If no date and time is included, the instrument should assume that the information system wants results + # going as far into the past as possible and consistent with the criteria specified in other fields. + "BeginningRequestResultsDateTime": 6, + # 11.8 Ending Request Results Date and Time + # This field, if not null, specifies the ending or latest (or most recent) date and time for which results are + # being requested. Date and time shall be represented as in Section 5.6.2. + "EndingRequestResultsDateTime": 7, + # 11.9 Requesting Physician Name + # This field identifies the individual physician requesting the results. The identity of the requesting + # physician is recorded as specified in Section 5.6.6. + "RequestingPhysicianName": 8, + # 11.10 Requesting Physician Telephone Number + # This field is as specified in Section 5.6.3. + "RequestingPhysicianTelephoneNumber": 9, + # 11.11 User Field Number 1 + # This is a user-defined field. + "UserFieldNumber1": 10, + # 11.12 User Field Number 2 + # User Field Number 2 + "UserFieldNumber1": 10, + # 11.13 Request Information Status Codes + # The following codes shall be used: + # C: correction of previously transmitted results + # P: preliminary results + # F: final results + # X: results cannot be done, request cancelled + # I: request results pending + # S: request partial/unfinalized results + # M: result is an MIC level + # R: this result was previously transmitted + # A: abort/cancel last request criteria (allows a new request to follow) + # N: requesting new or edited results only + # O: requesting test orders and demographics only (no results) + # D: requesting demographics only (e.g., patient record) + "QueryStatus": 11 + }, # TODO Message Terminator Record. See section 12 "L": {}, # TODO Scientific Record. See section 13 - "S": {}, + "S": {}, # TODO Manufacturer Information Record. See section 14 "M": {}, diff --git a/src/senaite/lis2a/tests/doctests/API.rst b/src/senaite/lis2a/tests/doctests/API.rst index 3c5a44f..5a96bad 100644 --- a/src/senaite/lis2a/tests/doctests/API.rst +++ b/src/senaite/lis2a/tests/doctests/API.rst @@ -30,7 +30,6 @@ Create some basic objects for the test: >>> setRoles(portal, TEST_USER_ID, ["LabManager", "Manager"]) >>> utils.setup_baseline_data(portal) - Retrieve built-in interpreters ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -212,3 +211,27 @@ If we try to reimport the same message, nothing happens: >>> api.import_message(message) False + +Extracting query from a message +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +We can directly extract the query from a message: + + >>> message = """ + ... H|\^&||||||||||P|LIS2-A2|19890327141200 + ... Q|1|^2345||||20160315161239|||||O + ... L|1|N + ... """ + >>> interpreter = api.get_interpreter_for(message) + >>> queries = api.extract_queries(message, interpreter) + +And we get one result for each (R)esult record, with the rest of result records +as interim fields: + + >>> len(queries) + 1 + >>> query = queries[0] + >>> query["id"] + '1' + >>> '2345' in query["specimen_id"] + True From 907e08853ff724cfa991ed033dc8fa170d9d7101 Mon Sep 17 00:00:00 2001 From: Mike Metcalfe Date: Tue, 11 Apr 2023 12:42:21 +0200 Subject: [PATCH 5/8] Extended PushConsumer test --- CHANGES.rst | 9 ++++++++- setup.py | 5 ++--- src/senaite/lis2a/interpreter/lis2a2.py | 2 +- src/senaite/lis2a/profiles/default/metadata.xml | 2 +- src/senaite/lis2a/tests/doctests/API.rst | 5 ++--- .../lis2a/tests/doctests/PushConsumer.rst | 17 +++++++++++++++++ 6 files changed, 31 insertions(+), 9 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 048f650..904f84e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,7 +1,14 @@ Changelog ========= -2.0.0 (unreleased) +2.0.1 (unreleased) +------------------ + +- Fixed broken tests +- Add LIS2-A2 Request Information capabilities + + +2.0.0 ------------------ - Compatibility with SENAITE 2.x and senaite.astm diff --git a/setup.py b/setup.py index b86a57f..0091264 100644 --- a/setup.py +++ b/setup.py @@ -1,13 +1,12 @@ from setuptools import setup, find_packages -version = "2.0.0" +version = "2.0.1" setup( name="senaite.lis2a", version=version, description="CLIS LIS2-A results import for SENAITE", - long_description=open("README.rst").read() + "\n" + - open("CHANGES.rst").read() + "\n", + long_description=open("README.rst").read() + "\n" + open("CHANGES.rst").read() + "\n", # Get more strings from # http://pypi.python.org/pypi?:action=list_classifiers classifiers=[ diff --git a/src/senaite/lis2a/interpreter/lis2a2.py b/src/senaite/lis2a/interpreter/lis2a2.py index 40ce526..b2955a6 100644 --- a/src/senaite/lis2a/interpreter/lis2a2.py +++ b/src/senaite/lis2a/interpreter/lis2a2.py @@ -556,7 +556,7 @@ "UserFieldNumber1": 10, # 11.12 User Field Number 2 # User Field Number 2 - "UserFieldNumber1": 10, + "UserFieldNumber2": 10, # 11.13 Request Information Status Codes # The following codes shall be used: # C: correction of previously transmitted results diff --git a/src/senaite/lis2a/profiles/default/metadata.xml b/src/senaite/lis2a/profiles/default/metadata.xml index 4078fb3..4b9c90d 100644 --- a/src/senaite/lis2a/profiles/default/metadata.xml +++ b/src/senaite/lis2a/profiles/default/metadata.xml @@ -6,7 +6,7 @@ dependencies before installing this add-on own profile. --> - 1.0.0 + 2.0.1 diff --git a/src/senaite/lis2a/tests/doctests/API.rst b/src/senaite/lis2a/tests/doctests/API.rst index 5a96bad..33e31b6 100644 --- a/src/senaite/lis2a/tests/doctests/API.rst +++ b/src/senaite/lis2a/tests/doctests/API.rst @@ -219,14 +219,13 @@ We can directly extract the query from a message: >>> message = """ ... H|\^&||||||||||P|LIS2-A2|19890327141200 - ... Q|1|^2345||||20160315161239|||||O + ... Q|1|^2345|||||||||O ... L|1|N ... """ >>> interpreter = api.get_interpreter_for(message) >>> queries = api.extract_queries(message, interpreter) -And we get one result for each (R)esult record, with the rest of result records -as interim fields: +And we get one query for the details of a sample: >>> len(queries) 1 diff --git a/src/senaite/lis2a/tests/doctests/PushConsumer.rst b/src/senaite/lis2a/tests/doctests/PushConsumer.rst index de6e2d9..96cc4b3 100644 --- a/src/senaite/lis2a/tests/doctests/PushConsumer.rst +++ b/src/senaite/lis2a/tests/doctests/PushConsumer.rst @@ -108,6 +108,23 @@ And both samples are now in "to_be_verified" status: ['to_be_verified', 'to_be_verified'] +Send message via push to query a sample +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Propare a query message + + >>> message = """ + ... H|\^&||||||||||P|LIS2-A2|19890327141200 + ... Q|1|^2345|||||||||O + ... L|1|N + ... """ + >>> payload = { + ... "consumer": "senaite.lis2a.import", + ... "messages": message, + ... } + >>> post("push", payload) + '..."success": true...' + .. Links .. _senaite.lis2a: https://pypi.python.org/pypi/senaite.lis2a From ce6f9bb303bc8de50b27634a97b83d3cbb0ffe5c Mon Sep 17 00:00:00 2001 From: Mike Metcalfe Date: Tue, 11 Apr 2023 15:41:13 +0200 Subject: [PATCH 6/8] Added query keyword (Q.UniversalTestID) --- src/senaite/lis2a/interpreter/__init__.py | 10 ++--- src/senaite/lis2a/interpreter/lis2a2.py | 39 +++++++++++++++++-- src/senaite/lis2a/tests/doctests/API.rst | 4 +- .../lis2a/tests/doctests/PushConsumer.rst | 2 +- 4 files changed, 44 insertions(+), 11 deletions(-) diff --git a/src/senaite/lis2a/interpreter/__init__.py b/src/senaite/lis2a/interpreter/__init__.py index e768145..d73ae54 100644 --- a/src/senaite/lis2a/interpreter/__init__.py +++ b/src/senaite/lis2a/interpreter/__init__.py @@ -487,13 +487,11 @@ def to_query_data(self, record): """ def resolve_query_mappings(record): - sequence_num = self.get_sequnce_number(record) - patient_id = self.get_patient_id(record) - specimen_id = self.get_specimen_id(record) return { - "id": sequence_num, - "patient_id": patient_id, - "specimen_id": specimen_id, + "id": self.get_sequnce_number(record), + "patient_id": self.get_patient_id(record), + "specimen_id": self.get_specimen_id(record), + "keyword": self.get_analysis_keywords(record), } # Resolve mappings for result diff --git a/src/senaite/lis2a/interpreter/lis2a2.py b/src/senaite/lis2a/interpreter/lis2a2.py index b2955a6..5627da1 100644 --- a/src/senaite/lis2a/interpreter/lis2a2.py +++ b/src/senaite/lis2a/interpreter/lis2a2.py @@ -48,6 +48,9 @@ "R.UniversalTestID", "R.UniversalTestID_Name", "R.UniversalTestID_ManufacturerCode", + "Q.UniversalTestID", + "Q.UniversalTestID_Name", + "Q.UniversalTestID_ManufacturerCode", ], "result": "R.Measurement", "capture_date": "R.DateTimeStarted", @@ -527,7 +530,22 @@ # by repeat delimiters, or the field may contain the text ALL, which signifies a request for all results on all # tests or batteries for the patients/specimens/tests defined in Sections 11.3 and 11.4 and within the dates # described in Sections 11.6 and 11.7. - "UniversalTestID": 4, + # This field shall use universal test ID as described in Section 5.6.1. + "UniversalTestID": (4, 0), + # The test or battery name associated with the universal test ID code + # described above + "UniversalTestID_Name": (4, 1), + # In the case where multiple national or international coding schemes + # exist, this field may be used to determine what coding scheme is + # employed in the test ID and test ID name fields + "UniversalTestID_Type": (4, 2), + # This code may be a number, characters, or a multiple test designator + # based on manufacturer-defined delimiters (that is, AK.23.34-B). + # Extensions or qualifiers to this code may be followed by subsequent + # component fields which must be defined and documented by the + # manufacturer. For example, this code may represent a three-part + # identifier such as -Dilution^Diluent^Description + "UniversalTestID_ManufacturerCode": (4, 3), # 11.6 Nature of Request Time Limits # Specify whether the date and time limits specified in Sections 11.7 and 11.8 refer to the specimen collect # or ordered date (see Section 8.4.8) or test date (see Section 8.4.23): S indicates the specimen collect date; @@ -575,8 +593,23 @@ }, # TODO Message Terminator Record. See section 12 - "L": {}, - + "L": { + # 12.2 Sequence Number + # This field is as described in Section 5.6.7. (For this record type, the value of this field should always be + # 1.) + "SequenceNumber": 1, + # 12.3 Termination Code + # This field provides an explanation of the end of the session. + # Nil, N: normal termination + # T: sender aborted + # R: receiver requested abort + # E: unknown system error + # Q: error in last request for information + # I: no information available from last query + # F: last request for information processed + # NOTE: I or Q will terminate a request and allow processing of a new request record. + "TerminationCode": 2 + }, # TODO Scientific Record. See section 13 "S": {}, diff --git a/src/senaite/lis2a/tests/doctests/API.rst b/src/senaite/lis2a/tests/doctests/API.rst index 33e31b6..6d51d49 100644 --- a/src/senaite/lis2a/tests/doctests/API.rst +++ b/src/senaite/lis2a/tests/doctests/API.rst @@ -219,7 +219,7 @@ We can directly extract the query from a message: >>> message = """ ... H|\^&||||||||||P|LIS2-A2|19890327141200 - ... Q|1|^2345|||||||||O + ... Q|1|^2345||ALL^^^|||||||O ... L|1|N ... """ >>> interpreter = api.get_interpreter_for(message) @@ -234,3 +234,5 @@ And we get one query for the details of a sample: '1' >>> '2345' in query["specimen_id"] True + >>> 'ALL' in query["keyword"] + True diff --git a/src/senaite/lis2a/tests/doctests/PushConsumer.rst b/src/senaite/lis2a/tests/doctests/PushConsumer.rst index 96cc4b3..c1a79be 100644 --- a/src/senaite/lis2a/tests/doctests/PushConsumer.rst +++ b/src/senaite/lis2a/tests/doctests/PushConsumer.rst @@ -115,7 +115,7 @@ Propare a query message >>> message = """ ... H|\^&||||||||||P|LIS2-A2|19890327141200 - ... Q|1|^2345|||||||||O + ... Q|1|^2345||ALL^^^|||||||O ... L|1|N ... """ >>> payload = { From 7204d4276d4dfc9b1c1d9b7c8db42ab182d68f45 Mon Sep 17 00:00:00 2001 From: Mike Metcalfe Date: Wed, 3 May 2023 18:50:58 +0200 Subject: [PATCH 7/8] Added patient_id to API queries test --- src/senaite/lis2a/api/__init__.py | 2 ++ src/senaite/lis2a/tests/doctests/API.rst | 11 +++-------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/senaite/lis2a/api/__init__.py b/src/senaite/lis2a/api/__init__.py index edfee43..97eba35 100644 --- a/src/senaite/lis2a/api/__init__.py +++ b/src/senaite/lis2a/api/__init__.py @@ -29,6 +29,7 @@ import six from bika.lims import api from plone.resource.utils import iterDirectoriesOfType +from senaite.lis2a import logger from senaite.lis2a.interpreter import Interpreter from senaite.lis2a.interpreter import lis2a2 @@ -206,4 +207,5 @@ def extract_queries(message, interpreter): interpreter.read(message) query_data = interpreter.get_queries_data() interpreter.close() + logger.info('extract_queries returns {}'.format(query_data)) return query_data diff --git a/src/senaite/lis2a/tests/doctests/API.rst b/src/senaite/lis2a/tests/doctests/API.rst index 6d51d49..306f163 100644 --- a/src/senaite/lis2a/tests/doctests/API.rst +++ b/src/senaite/lis2a/tests/doctests/API.rst @@ -219,7 +219,7 @@ We can directly extract the query from a message: >>> message = """ ... H|\^&||||||||||P|LIS2-A2|19890327141200 - ... Q|1|^2345||ALL^^^|||||||O + ... Q|1|2^2345||ALL^^^|||||||O ... L|1|N ... """ >>> interpreter = api.get_interpreter_for(message) @@ -229,10 +229,5 @@ And we get one query for the details of a sample: >>> len(queries) 1 - >>> query = queries[0] - >>> query["id"] - '1' - >>> '2345' in query["specimen_id"] - True - >>> 'ALL' in query["keyword"] - True + >>> queries + [{'specimen_id': '2345', 'id': '1', 'keyword': ['ALL'], 'patient_id': '2'}] From 5cbd76cea0cebc77d858fa6f571bc132ae6f041c Mon Sep 17 00:00:00 2001 From: Mike Metcalfe Date: Sun, 21 May 2023 17:27:33 +0200 Subject: [PATCH 8/8] Added process_query --- src/senaite/lis2a/adapters/__init__.py | 4 ++ src/senaite/lis2a/api/__init__.py | 17 +++++-- src/senaite/lis2a/api/query.py | 70 ++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 src/senaite/lis2a/api/query.py diff --git a/src/senaite/lis2a/adapters/__init__.py b/src/senaite/lis2a/adapters/__init__.py index 40e923c..a69e350 100644 --- a/src/senaite/lis2a/adapters/__init__.py +++ b/src/senaite/lis2a/adapters/__init__.py @@ -22,6 +22,7 @@ from Products.CMFCore.interfaces import IContentish from senaite.jsonapi.interfaces import IPushConsumer from senaite.lis2a import api as _api +from senaite.lis2a import logger from senaite.lis2a.api import message as msgapi from zope.component import adapts from zope.interface import implements @@ -43,6 +44,7 @@ class PushConsumer(object): implements(IPushConsumer) def __init__(self, data): + logger.info("PushConsumer: init with data {}".format(data)) self.data = data def process(self): @@ -57,6 +59,7 @@ def process(self): if not messages: raise ValueError("No messages found: {}".format(repr(self.data))) + logger.info("PushConsumer: process message {}".format(messages)) # Just in case we got a message instead of a list of messages if isinstance(messages, six.string_types): messages = (messages,) @@ -80,6 +83,7 @@ def process(self): # returns True if found a match in SENAITE and succeed on the result # import. It might happen there is no object in SENAITE matching with # the message we are trying to import. + logger.info("PushConsumer: process complete") return True diff --git a/src/senaite/lis2a/api/__init__.py b/src/senaite/lis2a/api/__init__.py index 97eba35..459b3d5 100644 --- a/src/senaite/lis2a/api/__init__.py +++ b/src/senaite/lis2a/api/__init__.py @@ -26,6 +26,7 @@ import analysis as anapi import json import message as msgapi +import query as query_api import six from bika.lims import api from plone.resource.utils import iterDirectoriesOfType @@ -97,12 +98,19 @@ def import_message(message): if not interpreter: raise ValueError("No interpreter found for {}".format(message)) + # Determine if resuls or query # Extract and import (R)esults results = extract_results(message, interpreter) - imported = any(map(anapi.import_result, results)) - - # Extract and import other data - return imported + if results: + imported = any(map(anapi.import_result, results)) + # Extract and import other data + logger.info('Imported results: {}'.format(imported)) + return imported + results = extract_queries(message, interpreter) + if results: + processed = query_api.process_query(results) + logger.info('Processed query: {}'.format(processed)) + return processed def extract_results(message, interpreter): @@ -204,6 +212,7 @@ def extract_queries(message, interpreter): queries, so it returns a list of dicts, and each dict represents a query """ + logger.info('extract_queries message in: {}'.format(message)) interpreter.read(message) query_data = interpreter.get_queries_data() interpreter.close() diff --git a/src/senaite/lis2a/api/query.py b/src/senaite/lis2a/api/query.py new file mode 100644 index 0000000..71a92b7 --- /dev/null +++ b/src/senaite/lis2a/api/query.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +# +# This file is part of SENAITE.LIS2A. +# +# SENAITE.LIS2A is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, version 2. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., 51 +# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Copyright 2020 by it's authors. +# Some rights reserved, see README and LICENSE. + + +from senaite.lis2a import logger +from analysis import search_analysis_container + +_marker = object() + + +def process_query(data): + """Tries to process an query + :param data: dict representation of a query, suitable for excecution + + data = [{ + 'specimen_id': 'AP-0001', + 'id': '1', + 'keyword': ['ALL'], + 'patient_id': 'HH-23-P000001' + }] + + "specimen_id" is used to identify to the sample/analysis_request + """ + for query in data: + specimen_id = query.get("specimen_id") + keyword = query.get("keyword") + if not all([keyword, specimen_id]): + logger.error("specifmen_id or keyword are missing or empty") + continue + + logger.info("Start processing {} {}".format(specimen_id, keyword)) + + # Look for matches + sample = search_analysis_container([specimen_id]) + if not sample: + logger.error("no match found for speciment {}" + .format(repr(specimen_id))) + return False + + analyses = sample.getAnalyses() + if len(analyses) == 0: + logger.error("No analyses found in sample {}".format(sample.getId())) + continue + + analyses = [an for an in analyses if an.review_state not in ['retracted', ]] + logger.info("Found {} active analysis".format(len(analyses))) + + # Construct ASTM order to send to instrument + + logger.info("Preceesed {} {}".format(specimen_id, keyword)) + + logger.info("process_query complete") + return True