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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cecc609c02db9bc3bfef3b86458006055944bf3bc7729446490c5375c9ceabd4
4
- data.tar.gz: 9992f6d05a17f52c725dc014876a29faadc1b96ba2711f26851e73c917e44e61
3
+ metadata.gz: 69ffcbf9ab0cea24c83a5e18f9298af7599090009eea8f17f111c37333902b64
4
+ data.tar.gz: 2e4dab1daa94bfa9af75510691c40505468bfb4faaa5410eaaf6cf43ee47e8f8
5
5
  SHA512:
6
- metadata.gz: 4d37c96cfad06c9d7c8a62dab06ab9c4a2ecbb7ffed973287d09abfe5f7f3fb9d721ae5f473edc1aab7ba100cf4f525af329f8797e319cfb534f7141e96d12c3
7
- data.tar.gz: 2e88ceb111fa7a74be37c78d8b7df7a6b179f28c38ee9824d829097e8568c058ea9996afd2105fff44d61e51aa37184d495f77fd6ed6ee84e3ffd86419cc8e9f
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
- Contribution directions go here.
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
@@ -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