his_emr_api_lab 1.0.5 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +20 -1
- data/app/jobs/lab/update_patient_orders_job.rb +20 -0
- data/app/jobs/lab/void_order_job.rb +18 -0
- data/app/models/lab/lab_order.rb +4 -0
- data/app/serializers/lab/lab_order_serializer.rb +7 -1
- data/app/services/lab/lims/api/rest_api.rb +344 -0
- data/app/services/lab/lims/api/ws_api.rb +121 -0
- data/app/services/lab/lims/config.rb +45 -8
- data/app/services/lab/lims/migrator.rb +12 -9
- data/app/services/lab/lims/order_serializer.rb +31 -8
- data/app/services/lab/lims/pull_worker.rb +295 -0
- data/app/services/lab/lims/push_worker.rb +103 -0
- data/app/services/lab/lims/utils.rb +6 -1
- data/app/services/lab/lims/worker.rb +40 -317
- data/app/services/lab/orders_search_service.rb +18 -0
- data/app/services/lab/orders_service.rb +4 -2
- data/lib/lab/version.rb +1 -1
- metadata +22 -3
- data/app/services/lab/lims/failed_imports.rb +0 -33
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 69ffcbf9ab0cea24c83a5e18f9298af7599090009eea8f17f111c37333902b64
|
4
|
+
data.tar.gz: 2e4dab1daa94bfa9af75510691c40505468bfb4faaa5410eaaf6cf43ee47e8f8
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 951f366fed5cc8ac190a7b73bcf958a9f93eb233d724fb9b30d42b9f4b5de78985cd20a01cc262fa85a795fb4fdf86d3d8e912e11256b25b980605493319f736
|
7
|
+
data.tar.gz: da971e7cb0f69ea705fcf2b8f9a52d2101959183b20004c0330c2103ec49d03a3c16492b8e3908d3f8ffd13f128a444f6b011c54fdfd37fb982ab392b840a0f0
|
data/README.md
CHANGED
@@ -43,9 +43,28 @@ Finally run:
|
|
43
43
|
$ bundle exec rails lab:install
|
44
44
|
```
|
45
45
|
|
46
|
+
## Configuration
|
47
|
+
|
48
|
+
This module in most cases should work without any configuration, however to enable
|
49
|
+
certain features some configuration may be required. Visit the
|
50
|
+
[configuration](./docs/configuration.md) page to learn how to configure the
|
51
|
+
application.
|
52
|
+
|
46
53
|
## Contributing
|
47
54
|
|
48
|
-
|
55
|
+
Fork this application create a branch for the contribution you want to make,
|
56
|
+
push your changes to the branch and then issue a pull request. You may want
|
57
|
+
to create a new first on our repository, so that your pull request references
|
58
|
+
this issue.
|
59
|
+
|
60
|
+
If you are fixing a bug, it will be nice to add a unit test that exposes
|
61
|
+
the bug. Although this is not a requirement in most cases.
|
62
|
+
|
63
|
+
Be sure to follow [this](https://github.com/rubocop/ruby-style-guide) Ruby
|
64
|
+
style guide. We don't necessarily look for strict adherence to the guidelines
|
65
|
+
but too much a departure from it is frowned upon. For example, you will be forgiven
|
66
|
+
for writing a method with 15 to 20 lines if you clearly justify why you couldn't
|
67
|
+
break that method into multiple smaller methods.
|
49
68
|
|
50
69
|
## License
|
51
70
|
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Lab
|
4
|
+
##
|
5
|
+
# Fetches updates on a patient's orders from external sources.
|
6
|
+
class UpdatePatientOrdersJob < ApplicationJob
|
7
|
+
queue_as :default
|
8
|
+
|
9
|
+
def perform(patient_id)
|
10
|
+
Rails.logger.info('Initialising LIMS REST API...')
|
11
|
+
|
12
|
+
User.current = Lab::Lims::Utils.lab_user
|
13
|
+
Location.current = Location.find_by_name('ART clinic')
|
14
|
+
|
15
|
+
lims_api = Lab::Lims::Api::RestApi.new
|
16
|
+
worker = Lab::Lims::Worker.new(lims_api)
|
17
|
+
worker.pull_orders(patient_id: patient_id)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Lab
|
4
|
+
class VoidOrderJob < ApplicationJob
|
5
|
+
queue_as :default
|
6
|
+
|
7
|
+
def perform(order_id)
|
8
|
+
Rails.logger.info("Voiding order ##{order_id} in LIMS")
|
9
|
+
|
10
|
+
User.current = Lab::Lims::Utils.lab_user
|
11
|
+
Location.current = Location.find_by_name('ART clinic')
|
12
|
+
|
13
|
+
lims_api = Lab::Lims::Api::RestApi.new
|
14
|
+
worker = Lab::Lims::Worker.new(lims_api)
|
15
|
+
worker.push_order(Lab::LabOrder.unscoped.find(order_id))
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
data/app/models/lab/lab_order.rb
CHANGED
@@ -41,6 +41,10 @@ module Lab
|
|
41
41
|
.where.not(concept_id: ConceptName.where(name: 'Tests ordered').select(:concept_id))
|
42
42
|
end
|
43
43
|
|
44
|
+
scope :drawn, -> { where.not(concept_id: ConceptName.where(name: 'Unknown').select(:concept_id)) }
|
45
|
+
|
46
|
+
scope :not_drawn, -> { where(concept_id: ConceptName.where(name: 'Unknown').select(:concept_id)) }
|
47
|
+
|
44
48
|
def self.prefetch_relationships
|
45
49
|
includes(:reason_for_test,
|
46
50
|
:requesting_clinician,
|
@@ -3,7 +3,7 @@
|
|
3
3
|
module Lab
|
4
4
|
module LabOrderSerializer
|
5
5
|
def self.serialize_order(order, tests: nil, requesting_clinician: nil, reason_for_test: nil, target_lab: nil)
|
6
|
-
tests ||= order.tests
|
6
|
+
tests ||= order.voided == 1 ? voided_tests(order) : order.tests
|
7
7
|
requesting_clinician ||= order.requesting_clinician
|
8
8
|
reason_for_test ||= order.reason_for_test
|
9
9
|
target_lab ||= order.target_lab
|
@@ -45,5 +45,11 @@ module Lab
|
|
45
45
|
|
46
46
|
ConceptName.select(:name).find_by_concept_id(concept_id)&.name
|
47
47
|
end
|
48
|
+
|
49
|
+
def self.voided_tests(order)
|
50
|
+
concept = ConceptName.where(name: Lab::Metadata::TEST_TYPE_CONCEPT_NAME)
|
51
|
+
.select(:concept_id)
|
52
|
+
LabTest.unscoped.where(concept: concept, order: order, voided: true)
|
53
|
+
end
|
48
54
|
end
|
49
55
|
end
|
@@ -0,0 +1,344 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Lab::Lims::Api::RestApi
|
4
|
+
class LimsApiError < GatewayError; end
|
5
|
+
|
6
|
+
class AuthenticationTokenExpired < LimsApiError; end
|
7
|
+
|
8
|
+
class InvalidParameters < LimsApiError; end
|
9
|
+
|
10
|
+
def initialize(config)
|
11
|
+
@config = config
|
12
|
+
end
|
13
|
+
|
14
|
+
def create_order(order_dto)
|
15
|
+
response = in_authenticated_session do |headers|
|
16
|
+
Rails.logger.info("Pushing order ##{order_dto[:tracking_number]} to LIMS")
|
17
|
+
|
18
|
+
if order_dto['sample_type'].casecmp?('not_specified')
|
19
|
+
RestClient.post(expand_uri('request_order', api_version: 'v2'), make_create_params(order_dto), headers)
|
20
|
+
else
|
21
|
+
RestClient.post(expand_uri('create_order'), make_create_params(order_dto), headers)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
update_order_results(order_dto)
|
26
|
+
|
27
|
+
data = JSON.parse(response.body)['data']
|
28
|
+
|
29
|
+
ActiveSupport::HashWithIndifferentAccess.new(
|
30
|
+
id: data['tracking_number'],
|
31
|
+
rev: 0,
|
32
|
+
tracking_number: data['tracking_number']
|
33
|
+
)
|
34
|
+
end
|
35
|
+
|
36
|
+
def update_order(_id, order_dto)
|
37
|
+
in_authenticated_session do |headers|
|
38
|
+
RestClient.post(expand_uri('update_order'), make_update_params(order_dto), headers)
|
39
|
+
end
|
40
|
+
|
41
|
+
update_order_results(order_dto)
|
42
|
+
|
43
|
+
{ tracking_number: order_dto[:tracking_number] }
|
44
|
+
end
|
45
|
+
|
46
|
+
def consume_orders(*_args, patient_id: nil, **_kwargs)
|
47
|
+
Rails.logger.info('Looking for orders without results...')
|
48
|
+
orders = Lab::OrdersSearchService.find_orders_without_results(patient_id: patient_id)
|
49
|
+
|
50
|
+
orders.each do |order|
|
51
|
+
response = in_authenticated_session do |headers|
|
52
|
+
Rails.logger.info("Fetching results for order ##{order.accession_number}")
|
53
|
+
RestClient.get(expand_uri("query_results_by_tracking_number/#{order.accession_number}"), headers)
|
54
|
+
end
|
55
|
+
|
56
|
+
Rails.logger.info("Result for order ##{order.accession_number} found... Parsing...")
|
57
|
+
results = JSON.parse(response).fetch('data').fetch('results')
|
58
|
+
order_dto = Lab::Lims::OrderSerializer.serialize_order(order)
|
59
|
+
yield lims_results_to_order_dto(results, order_dto), OpenStruct.new(last_seq: 0)
|
60
|
+
rescue InvalidParameters => e # LIMS responds with a 401 when a result is not found :(
|
61
|
+
Rails.logger.error("Failed to fetch results for ##{order.accession_number}: #{e.message}")
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def delete_order(_id, order_dto)
|
66
|
+
tracking_number = order_dto.fetch('tracking_number')
|
67
|
+
|
68
|
+
order_dto['tests'].each do |test|
|
69
|
+
Rails.logger.info("Voiding test '#{test}' (#{tracking_number}) in LIMS")
|
70
|
+
in_authenticated_session do |headers|
|
71
|
+
date_voided, voided_status = find_test_status(order_dto, test, 'Voided')
|
72
|
+
params = make_void_test_params(tracking_number, test, voided_status['updated_by'], date_voided)
|
73
|
+
RestClient.post(expand_uri('update_test'), params, headers)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
private
|
79
|
+
|
80
|
+
attr_reader :config
|
81
|
+
|
82
|
+
MAX_LIMS_RETRIES = 5 # LIMS API Calls can only fail this number of times before we give up on it
|
83
|
+
|
84
|
+
##
|
85
|
+
# Execute LIMS API calls within an authenticated session.
|
86
|
+
#
|
87
|
+
# Method automatically checks authenticates with LIMS if necessary and passes
|
88
|
+
# down the necessary headers for authentication to the REST call being made.
|
89
|
+
#
|
90
|
+
# Example:
|
91
|
+
#
|
92
|
+
# response = in_authenticated_session do |headers|
|
93
|
+
# RestClient.get(expand_uri('query_results_by_tracking_number/XXXXXX'), headers)
|
94
|
+
# end
|
95
|
+
#
|
96
|
+
# pp JSON.parse(response.body) if response.code == 200
|
97
|
+
def in_authenticated_session
|
98
|
+
retries ||= MAX_LIMS_RETRIES
|
99
|
+
|
100
|
+
self.authentication_token = authenticate unless authentication_token
|
101
|
+
|
102
|
+
response = yield 'token' => authentication_token, 'Content-type' => 'application/json'
|
103
|
+
check_response!(response)
|
104
|
+
rescue AuthenticationTokenExpired => e
|
105
|
+
self.authentication_token = nil
|
106
|
+
retry if (retries -= 1).positive?
|
107
|
+
rescue RestClient::ExceptionWithResponse => e
|
108
|
+
Rails.logger.error("LIMS Error: #{e.response.code} - #{e.response.body}")
|
109
|
+
raise e unless e.response.code == 401
|
110
|
+
|
111
|
+
self.authentication_token = nil
|
112
|
+
retry if (retries -= 1).positive?
|
113
|
+
end
|
114
|
+
|
115
|
+
def authenticate
|
116
|
+
username = config.fetch(:username)
|
117
|
+
password = config.fetch(:password)
|
118
|
+
|
119
|
+
Rails.logger.debug("Authenticating with LIMS as: #{username}")
|
120
|
+
response = RestClient.get(expand_uri("re_authenticate/#{username}/#{password}"), headers: { 'Content-type' => 'application/json' })
|
121
|
+
response_body = JSON.parse(response.body)
|
122
|
+
|
123
|
+
if response_body['status'] == 401
|
124
|
+
Rails.logger.error("Failed to authenticate with LIMS as #{config.fetch(:username)}: #{response_body['message']}")
|
125
|
+
raise LimsApiError, 'LIMS authentication failed'
|
126
|
+
end
|
127
|
+
|
128
|
+
response_body['data']['token']
|
129
|
+
end
|
130
|
+
|
131
|
+
def authentication_token=(token)
|
132
|
+
Thread.current[:lims_authentication_token] = token
|
133
|
+
end
|
134
|
+
|
135
|
+
def authentication_token
|
136
|
+
Thread.current[:lims_authentication_token]
|
137
|
+
end
|
138
|
+
|
139
|
+
##
|
140
|
+
# Examines a response from LIMS to check if token has expired.
|
141
|
+
#
|
142
|
+
# LIMS' doesn't properly use HTTP status codes; the codes are embedded in the
|
143
|
+
# response body. 200 is used for success responses and 401 for everything else.
|
144
|
+
# We have this work around to examine the response body and
|
145
|
+
# throw errors accordingly. The following are the errors thrown:
|
146
|
+
#
|
147
|
+
# Lims::AuthenticationTokenExpired
|
148
|
+
# Lims::InvalidParameters
|
149
|
+
# Lims::ApiError - Thrown when we couldn't make sense of the error
|
150
|
+
def check_response!(response)
|
151
|
+
body = JSON.parse(response.body)
|
152
|
+
return response if body['status'] == 200
|
153
|
+
|
154
|
+
Rails.logger.error("Lims Api Error: #{response.body}")
|
155
|
+
|
156
|
+
if body['status'] != 401
|
157
|
+
raise LimsApiError, "#{body['status']} - #{body['message']}"
|
158
|
+
end
|
159
|
+
|
160
|
+
if body['message'].match?(/token expired/i)
|
161
|
+
raise AuthenticationTokenExpired, "Authentication token expired: #{body['message']}"
|
162
|
+
end
|
163
|
+
|
164
|
+
raise InvalidParameters, body['message']
|
165
|
+
end
|
166
|
+
|
167
|
+
##
|
168
|
+
# Takes a LIMS API relative URI and converts it to a full URL.
|
169
|
+
def expand_uri(uri, api_version: 'v1')
|
170
|
+
protocol = config.fetch(:protocol)
|
171
|
+
host = config.fetch(:host)
|
172
|
+
port = config.fetch(:port)
|
173
|
+
uri = uri.gsub(%r{^/+}, '')
|
174
|
+
|
175
|
+
"#{protocol}://#{host}:#{port}/api/#{api_version}/#{uri}"
|
176
|
+
end
|
177
|
+
|
178
|
+
##
|
179
|
+
# Converts an OrderDTO to parameters for POST /create_order
|
180
|
+
def make_create_params(order_dto)
|
181
|
+
{
|
182
|
+
tracking_number: order_dto.fetch(:tracking_number),
|
183
|
+
district: current_district,
|
184
|
+
health_facility_name: order_dto.fetch(:sending_facility),
|
185
|
+
first_name: order_dto.fetch(:patient).fetch(:first_name),
|
186
|
+
last_name: order_dto.fetch(:patient).fetch(:last_name),
|
187
|
+
phone_number: order_dto.fetch(:patient).fetch(:phone_number),
|
188
|
+
gender: order_dto.fetch(:patient).fetch(:gender),
|
189
|
+
national_patient_id: order_dto.fetch(:patient).fetch(:id),
|
190
|
+
requesting_clinician: requesting_clinician(order_dto),
|
191
|
+
sample_type: order_dto.fetch(:sample_type),
|
192
|
+
tests: order_dto.fetch(:tests),
|
193
|
+
date_sample_drawn: sample_drawn_date(order_dto),
|
194
|
+
sample_priority: order_dto.fetch(:priority) || 'Routine',
|
195
|
+
sample_status: order_dto.fetch(:sample_status),
|
196
|
+
target_lab: order_dto.fetch(:receiving_facility),
|
197
|
+
order_location: order_dto.fetch(:order_location) || 'Unknown',
|
198
|
+
who_order_test_first_name: order_dto.fetch(:who_order_test).fetch(:first_name),
|
199
|
+
who_order_test_last_name: order_dto.fetch(:who_order_test).fetch(:last_name)
|
200
|
+
}
|
201
|
+
end
|
202
|
+
|
203
|
+
##
|
204
|
+
# Converts an OrderDTO to parameters for POST /update_order
|
205
|
+
def make_update_params(order_dto)
|
206
|
+
date_updated, status = sample_drawn_status(order_dto)
|
207
|
+
|
208
|
+
{
|
209
|
+
tracking_number: order_dto.fetch(:tracking_number),
|
210
|
+
who_updated: status.fetch(:updated_by),
|
211
|
+
date_updated: date_updated,
|
212
|
+
specimen_type: order_dto.fetch(:sample_type),
|
213
|
+
status: 'specimen_collected'
|
214
|
+
}
|
215
|
+
end
|
216
|
+
|
217
|
+
def current_district
|
218
|
+
health_centre = Location.current_health_center
|
219
|
+
raise 'Current health centre not set' unless health_centre
|
220
|
+
|
221
|
+
district = health_centre.district || Lab::Lims::Config.application['district']
|
222
|
+
|
223
|
+
unless district
|
224
|
+
health_centre_name = "##{health_centre.id} - #{health_centre.name}"
|
225
|
+
raise "Current health centre district not set: #{health_centre_name}"
|
226
|
+
end
|
227
|
+
|
228
|
+
district
|
229
|
+
end
|
230
|
+
|
231
|
+
##
|
232
|
+
# Extracts sample drawn status from an OrderDTO
|
233
|
+
def sample_drawn_status(order_dto)
|
234
|
+
order_dto[:sample_statuses].each do |trail_entry|
|
235
|
+
date, status = trail_entry.each_pair.find { |_date, status| status['status'].casecmp?('Drawn') }
|
236
|
+
next unless date
|
237
|
+
|
238
|
+
return Date.strptime(date, '%Y%m%d%H%M%S').strftime('%Y-%m-%d'), status
|
239
|
+
end
|
240
|
+
|
241
|
+
[order_dto['date_created'], nil]
|
242
|
+
end
|
243
|
+
|
244
|
+
##
|
245
|
+
# Extracts a sample drawn date from a LIMS OrderDTO.
|
246
|
+
def sample_drawn_date(order_dto)
|
247
|
+
sample_drawn_status(order_dto).first
|
248
|
+
end
|
249
|
+
|
250
|
+
##
|
251
|
+
# Extracts the requesting clinician from a LIMS OrderDTO
|
252
|
+
def requesting_clinician(order_dto)
|
253
|
+
orderer = order_dto[:who_order_test]
|
254
|
+
|
255
|
+
"#{orderer[:first_name]} #{orderer[:last_name]}"
|
256
|
+
end
|
257
|
+
|
258
|
+
##
|
259
|
+
# Make a copy of the order_dto with the results from LIMS parsed
|
260
|
+
# and appended to it.
|
261
|
+
def lims_results_to_order_dto(results, order_dto)
|
262
|
+
order_dto.merge(
|
263
|
+
'_id' => order_dto[:tracking_number],
|
264
|
+
'_rev' => 0,
|
265
|
+
'test_results' => results.each_with_object({}) do |result, formatted_results|
|
266
|
+
test_name, measures = result
|
267
|
+
result_date = measures.delete('result_date')
|
268
|
+
|
269
|
+
formatted_results[test_name] = {
|
270
|
+
results: measures.each_with_object({}) do |measure, processed_measures|
|
271
|
+
processed_measures[measure[0]] = { 'result_value' => measure[1] }
|
272
|
+
end,
|
273
|
+
result_date: result_date,
|
274
|
+
result_entered_by: {}
|
275
|
+
}
|
276
|
+
end
|
277
|
+
)
|
278
|
+
end
|
279
|
+
|
280
|
+
def update_order_results(order_dto)
|
281
|
+
if order_dto['test_results'].nil? || order_dto['test_results'].empty?
|
282
|
+
return nil
|
283
|
+
end
|
284
|
+
|
285
|
+
order_dto['test_results'].each do |test_name, results|
|
286
|
+
Rails.logger.info("Pushing result for order ##{order_dto['tracking_number']}")
|
287
|
+
in_authenticated_session do |headers|
|
288
|
+
params = make_update_test_params(order_dto['tracking_number'], test_name, results)
|
289
|
+
|
290
|
+
RestClient.post(expand_uri('update_test'), params, headers)
|
291
|
+
end
|
292
|
+
end
|
293
|
+
end
|
294
|
+
|
295
|
+
def make_update_test_params(tracking_number, test_name, results, test_status = 'Drawn')
|
296
|
+
{
|
297
|
+
tracking_number: tracking_number,
|
298
|
+
test_name: test_name,
|
299
|
+
result_date: results['result_date'],
|
300
|
+
time_updated: results['result_date'],
|
301
|
+
who_updated: {
|
302
|
+
first_name: results[:result_entered_by][:first_name],
|
303
|
+
last_name: results[:result_entered_by][:last_name],
|
304
|
+
id_number: results[:result_entered_by][:id]
|
305
|
+
},
|
306
|
+
test_status: test_status,
|
307
|
+
results: results['results']&.each_with_object({}) do |measure, formatted_results|
|
308
|
+
measure_name, measure_value = measure
|
309
|
+
|
310
|
+
formatted_results[measure_name] = measure_value['result_value']
|
311
|
+
end
|
312
|
+
}
|
313
|
+
end
|
314
|
+
|
315
|
+
def find_test_status(order_dto, target_test, target_status)
|
316
|
+
order_dto['test_statuses'].each do |test, statuses|
|
317
|
+
next unless test.casecmp?(target_test)
|
318
|
+
|
319
|
+
statuses.each do |date, status|
|
320
|
+
next unless status['status'].casecmp?(target_status)
|
321
|
+
|
322
|
+
return [Date.strptime(date, '%Y%m%d%H%M%S'), status]
|
323
|
+
end
|
324
|
+
end
|
325
|
+
|
326
|
+
nil
|
327
|
+
end
|
328
|
+
|
329
|
+
def make_void_test_params(tracking_number, test_name, voided_by, void_date = nil)
|
330
|
+
void_date ||= Time.now
|
331
|
+
|
332
|
+
{
|
333
|
+
tracking_number: tracking_number,
|
334
|
+
test_name: test_name,
|
335
|
+
time_updated: void_date,
|
336
|
+
who_updated: {
|
337
|
+
first_name: voided_by[:first_name],
|
338
|
+
last_name: voided_by[:last_name],
|
339
|
+
id_number: voided_by[:id]
|
340
|
+
},
|
341
|
+
test_status: 'voided'
|
342
|
+
}
|
343
|
+
end
|
344
|
+
end
|