his_emr_api_lab 1.0.4 → 1.1.2

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: 6142a8351eea3dc6cc2dde34b014948bd95c5d4398355ddc29d2aa0ddff8c095
4
- data.tar.gz: ca4493c370000ab13cae02629c8cc3ea840f9bf03a70dee35a3f8bc3574e12e3
3
+ metadata.gz: e2202cc31522b4ae92847b3102841cfcf0c65a9e389fde480e3e787f1ef49ec7
4
+ data.tar.gz: a82291c66cdd6fb651e0917dc68e5b28b6dcedeb124973ab57f9f60187b4c920
5
5
  SHA512:
6
- metadata.gz: b18abf5a9f7e00c213ff41bea37c924f7c5abafbfefd5f63727c76093d26e057391ddb047e4a2583fbfdb9d98bd05895387cc6b26aacfe54df4a290cb804f9b9
7
- data.tar.gz: e7e23b2a1eb5d387855e7acc7e1bd60dcdfca08938245693a07833a6fc43314e1127a9108eaaf8e1536fa3e16acaaa74ecf104d107b3fb71a98ad9b0c4b8a875
6
+ metadata.gz: 7da2c231dba9fa4b013890becd128b0a8694fe797b510e9baa37638d1437133cc11e27e2479938eda921ccfe6c23583544092cb477372b5db298922913035c72
7
+ data.tar.gz: cb8fbf9e9c71d187e0085b42f0fe5299976dc4309d672c815c231cdb096548d9b53b9b0f222f453b66a41a8d18b759f2d8d295c46c899206d7c7ffcee500bb6c
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,33 @@
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
+ lockfile = Rails.root.join('tmp', "update-patient-orders-#{patient_id}.lock")
16
+
17
+ done = File.open(lockfile, File::RDWR | File::CREAT) do |lock|
18
+ unless lock.flock(File::LOCK_NB | File::LOCK_EX)
19
+ Rails.logger.info('Another update patient job is already running...')
20
+ break false
21
+ end
22
+
23
+ lims_api = Lab::Lims::Api::RestApi.new(Lab::Lims::Config.rest_api)
24
+ worker = Lab::Lims::PullWorker.new(lims_api)
25
+ worker.pull_orders(patient_id: patient_id)
26
+
27
+ true
28
+ end
29
+
30
+ File.unlink(lockfile) if done
31
+ end
32
+ end
33
+ 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,405 @@
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
+ data = JSON.parse(response.body)
26
+ update_order_results(order_dto) unless data['message'].casecmp?('Order already available')
27
+
28
+ ActiveSupport::HashWithIndifferentAccess.new(
29
+ id: order_dto.fetch(:_id, order_dto[:tracking_number]),
30
+ rev: 0,
31
+ tracking_number: order_dto[:tracking_number]
32
+ )
33
+ end
34
+
35
+ def update_order(_id, order_dto)
36
+ in_authenticated_session do |headers|
37
+ RestClient.post(expand_uri('update_order'), make_update_params(order_dto), headers)
38
+ end
39
+
40
+ update_order_results(order_dto)
41
+
42
+ { tracking_number: order_dto[:tracking_number] }
43
+ end
44
+
45
+ def consume_orders(*_args, patient_id: nil, **_kwargs)
46
+ orders_pending_updates(patient_id).each do |order|
47
+ order_dto = Lab::Lims::OrderSerializer.serialize_order(order)
48
+
49
+ if order_dto['priority'].nil? || order_dto['sample_type'].casecmp?('not_specified')
50
+ patch_order_dto_with_lims_order!(order_dto, find_lims_order(order.accession_number))
51
+ end
52
+
53
+ if order_dto['test_results'].empty?
54
+ begin
55
+ patch_order_dto_with_lims_results!(order_dto, find_lims_results(order.accession_number))
56
+ rescue InvalidParameters => e # LIMS responds with a 401 when a result is not found :(
57
+ Rails.logger.error("Failed to fetch results for ##{order.accession_number}: #{e.message}")
58
+ end
59
+ end
60
+
61
+ yield order_dto, OpenStruct.new(last_seq: 0)
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}"),
121
+ headers: { 'Content-type' => 'application/json' })
122
+ response_body = JSON.parse(response.body)
123
+
124
+ if response_body['status'] == 401
125
+ Rails.logger.error("Failed to authenticate with LIMS as #{config.fetch(:username)}: #{response_body['message']}")
126
+ raise LimsApiError, 'LIMS authentication failed'
127
+ end
128
+
129
+ response_body['data']['token']
130
+ end
131
+
132
+ def authentication_token=(token)
133
+ Thread.current[:lims_authentication_token] = token
134
+ end
135
+
136
+ def authentication_token
137
+ Thread.current[:lims_authentication_token]
138
+ end
139
+
140
+ ##
141
+ # Examines a response from LIMS to check if token has expired.
142
+ #
143
+ # LIMS' doesn't properly use HTTP status codes; the codes are embedded in the
144
+ # response body. 200 is used for success responses and 401 for everything else.
145
+ # We have this work around to examine the response body and
146
+ # throw errors accordingly. The following are the errors thrown:
147
+ #
148
+ # Lims::AuthenticationTokenExpired
149
+ # Lims::InvalidParameters
150
+ # Lims::ApiError - Thrown when we couldn't make sense of the error
151
+ def check_response!(response)
152
+ body = JSON.parse(response.body)
153
+ return response if body['status'] == 200
154
+
155
+ Rails.logger.error("Lims Api Error: #{response.body}")
156
+
157
+ raise LimsApiError, "#{body['status']} - #{body['message']}" if body['status'] != 401
158
+
159
+ if body['message'].match?(/token expired/i)
160
+ raise AuthenticationTokenExpired, "Authentication token expired: #{body['message']}"
161
+ end
162
+
163
+ raise InvalidParameters, body['message']
164
+ end
165
+
166
+ ##
167
+ # Takes a LIMS API relative URI and converts it to a full URL.
168
+ def expand_uri(uri, api_version: 'v1')
169
+ protocol = config.fetch(:protocol)
170
+ host = config.fetch(:host)
171
+ port = config.fetch(:port)
172
+ uri = uri.gsub(%r{^/+}, '')
173
+
174
+ "#{protocol}://#{host}:#{port}/api/#{api_version}/#{uri}"
175
+ end
176
+
177
+ ##
178
+ # Converts an OrderDTO to parameters for POST /create_order
179
+ def make_create_params(order_dto)
180
+ {
181
+ tracking_number: order_dto.fetch(:tracking_number),
182
+ district: current_district,
183
+ health_facility_name: order_dto.fetch(:sending_facility),
184
+ first_name: order_dto.fetch(:patient).fetch(:first_name),
185
+ last_name: order_dto.fetch(:patient).fetch(:last_name),
186
+ phone_number: order_dto.fetch(:patient).fetch(:phone_number),
187
+ gender: order_dto.fetch(:patient).fetch(:gender),
188
+ national_patient_id: order_dto.fetch(:patient).fetch(:id),
189
+ requesting_clinician: requesting_clinician(order_dto),
190
+ sample_type: order_dto.fetch(:sample_type),
191
+ tests: order_dto.fetch(:tests),
192
+ date_sample_drawn: sample_drawn_date(order_dto),
193
+ sample_priority: order_dto.fetch(:priority) || 'Routine',
194
+ sample_status: order_dto.fetch(:sample_status),
195
+ target_lab: order_dto.fetch(:receiving_facility),
196
+ order_location: order_dto.fetch(:order_location) || 'Unknown',
197
+ who_order_test_first_name: order_dto.fetch(:who_order_test).fetch(:first_name),
198
+ who_order_test_last_name: order_dto.fetch(:who_order_test).fetch(:last_name)
199
+ }
200
+ end
201
+
202
+ ##
203
+ # Converts an OrderDTO to parameters for POST /update_order
204
+ def make_update_params(order_dto)
205
+ date_updated, status = sample_drawn_status(order_dto)
206
+
207
+ {
208
+ tracking_number: order_dto.fetch(:tracking_number),
209
+ who_updated: status.fetch(:updated_by),
210
+ date_updated: date_updated,
211
+ specimen_type: order_dto.fetch(:sample_type),
212
+ status: 'specimen_collected'
213
+ }
214
+ end
215
+
216
+ def current_district
217
+ health_centre = Location.current_health_center
218
+ raise 'Current health centre not set' unless health_centre
219
+
220
+ district = health_centre.district || Lab::Lims::Config.application['district']
221
+
222
+ unless district
223
+ health_centre_name = "##{health_centre.id} - #{health_centre.name}"
224
+ raise "Current health centre district not set: #{health_centre_name}"
225
+ end
226
+
227
+ district
228
+ end
229
+
230
+ ##
231
+ # Extracts sample drawn status from an OrderDTO
232
+ def sample_drawn_status(order_dto)
233
+ order_dto[:sample_statuses].each do |trail_entry|
234
+ date, status = trail_entry.each_pair.find { |_date, status| status['status'].casecmp?('Drawn') }
235
+ next unless date
236
+
237
+ return Date.strptime(date, '%Y%m%d%H%M%S').strftime('%Y-%m-%d'), status
238
+ end
239
+
240
+ [order_dto['date_created'], nil]
241
+ end
242
+
243
+ ##
244
+ # Extracts a sample drawn date from a LIMS OrderDTO.
245
+ def sample_drawn_date(order_dto)
246
+ sample_drawn_status(order_dto).first
247
+ end
248
+
249
+ ##
250
+ # Extracts the requesting clinician from a LIMS OrderDTO
251
+ def requesting_clinician(order_dto)
252
+ orderer = order_dto[:who_order_test]
253
+
254
+ "#{orderer[:first_name]} #{orderer[:last_name]}"
255
+ end
256
+
257
+ def find_lims_order(tracking_number)
258
+ response = in_authenticated_session do |headers|
259
+ Rails.logger.info("Fetching order ##{tracking_number}")
260
+ RestClient.get(expand_uri("query_order_by_tracking_number/#{tracking_number}"), headers)
261
+ end
262
+
263
+ Rails.logger.info("Order ##{tracking_number} found... Parsing...")
264
+ JSON.parse(response).fetch('data')
265
+ end
266
+
267
+ def find_lims_results(tracking_number)
268
+ response = in_authenticated_session do |headers|
269
+ Rails.logger.info("Fetching results for order ##{tracking_number}")
270
+ RestClient.get(expand_uri("query_results_by_tracking_number/#{tracking_number}"), headers)
271
+ end
272
+
273
+ Rails.logger.info("Result for order ##{tracking_number} found... Parsing...")
274
+ JSON.parse(response).fetch('data').fetch('results')
275
+ end
276
+
277
+ ##
278
+ # Make a copy of the order_dto with the results from LIMS parsed
279
+ # and appended to it.
280
+ def patch_order_dto_with_lims_results!(order_dto, results)
281
+ order_dto.merge!(
282
+ '_id' => order_dto[:tracking_number],
283
+ '_rev' => 0,
284
+ 'test_results' => results.each_with_object({}) do |result, formatted_results|
285
+ test_name, measures = result
286
+ result_date = measures.delete('result_date')
287
+
288
+ formatted_results[test_name] = {
289
+ results: measures.each_with_object({}) do |measure, processed_measures|
290
+ processed_measures[measure[0]] = { 'result_value' => measure[1] }
291
+ end,
292
+ result_date: result_date,
293
+ result_entered_by: {}
294
+ }
295
+ end
296
+ )
297
+ end
298
+
299
+ def patch_order_dto_with_lims_order!(order_dto, lims_order)
300
+ order_dto.merge!(
301
+ 'sample_type' => lims_order['other']['sample_type'],
302
+ 'sample_status' => lims_order['other']['specimen_status'],
303
+ 'priority' => lims_order['other']['priority']
304
+ )
305
+ end
306
+
307
+ def update_order_results(order_dto)
308
+ return nil if order_dto['test_results'].nil? || order_dto['test_results'].empty?
309
+
310
+ order_dto['test_results'].each do |test_name, results|
311
+ Rails.logger.info("Pushing result for order ##{order_dto['tracking_number']}")
312
+ in_authenticated_session do |headers|
313
+ params = make_update_test_params(order_dto['tracking_number'], test_name, results)
314
+
315
+ RestClient.post(expand_uri('update_test'), params, headers)
316
+ end
317
+ end
318
+ end
319
+
320
+ def make_update_test_params(tracking_number, test_name, results, test_status = 'Drawn')
321
+ {
322
+ tracking_number: tracking_number,
323
+ test_name: test_name,
324
+ result_date: results['result_date'],
325
+ time_updated: results['result_date'],
326
+ who_updated: {
327
+ first_name: results[:result_entered_by][:first_name],
328
+ last_name: results[:result_entered_by][:last_name],
329
+ id_number: results[:result_entered_by][:id]
330
+ },
331
+ test_status: test_status,
332
+ results: results['results']&.each_with_object({}) do |measure, formatted_results|
333
+ measure_name, measure_value = measure
334
+
335
+ formatted_results[measure_name] = measure_value['result_value']
336
+ end
337
+ }
338
+ end
339
+
340
+ def find_test_status(order_dto, target_test, target_status)
341
+ order_dto['test_statuses'].each do |test, statuses|
342
+ next unless test.casecmp?(target_test)
343
+
344
+ statuses.each do |date, status|
345
+ next unless status['status'].casecmp?(target_status)
346
+
347
+ return [Date.strptime(date, '%Y%m%d%H%M%S'), status]
348
+ end
349
+ end
350
+
351
+ nil
352
+ end
353
+
354
+ def make_void_test_params(tracking_number, test_name, voided_by, void_date = nil)
355
+ void_date ||= Time.now
356
+
357
+ {
358
+ tracking_number: tracking_number,
359
+ test_name: test_name,
360
+ time_updated: void_date,
361
+ who_updated: {
362
+ first_name: voided_by[:first_name],
363
+ last_name: voided_by[:last_name],
364
+ id_number: voided_by[:id]
365
+ },
366
+ test_status: 'voided'
367
+ }
368
+ end
369
+
370
+ def orders_pending_updates(patient_id = nil)
371
+ Rails.logger.info('Looking for orders that need to be updated...')
372
+ orders = {}
373
+
374
+ orders_without_specimen(patient_id).each { |order| orders[order.order_id] = order }
375
+ orders_without_results(patient_id).each { |order| orders[order.order_id] = order }
376
+ orders_without_reason(patient_id).each { |order| orders[order.order_id] = order }
377
+
378
+ orders.values
379
+ end
380
+
381
+ def orders_without_specimen(patient_id = nil)
382
+ Rails.logger.debug('Looking for orders without a specimen')
383
+ unknown_specimen = ConceptName.where(name: Lab::Metadata::UNKNOWN_SPECIMEN)
384
+ .select(:concept_id)
385
+ orders = Lab::LabOrder.where(concept_id: unknown_specimen)
386
+ orders = orders.where(patient_id: patient_id) if patient_id
387
+
388
+ orders
389
+ end
390
+
391
+ def orders_without_results(patient_id = nil)
392
+ Rails.logger.debug('Looking for orders without a result')
393
+ Lab::OrdersSearchService.find_orders_without_results(patient_id: patient_id)
394
+ end
395
+
396
+ def orders_without_reason(patient_id = nil)
397
+ Rails.logger.debug('Looking for orders without a reason for test')
398
+ orders = Lab::LabOrder.joins(:reason_for_test)
399
+ .merge(Observation.where(value_coded: nil, value_text: nil))
400
+ .limit(1000)
401
+ orders = orders.where(patient_id: patient_id) if patient_id
402
+
403
+ orders
404
+ end
405
+ end