mahis_emr_api_lab 1.2.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.
Files changed (90) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +71 -0
  4. data/Rakefile +32 -0
  5. data/app/controllers/lab/application_controller.rb +6 -0
  6. data/app/controllers/lab/labels_controller.rb +17 -0
  7. data/app/controllers/lab/orders_controller.rb +78 -0
  8. data/app/controllers/lab/reasons_for_test_controller.rb +9 -0
  9. data/app/controllers/lab/results_controller.rb +20 -0
  10. data/app/controllers/lab/specimen_types_controller.rb +15 -0
  11. data/app/controllers/lab/test_result_indicators_controller.rb +9 -0
  12. data/app/controllers/lab/test_types_controller.rb +15 -0
  13. data/app/controllers/lab/tests_controller.rb +25 -0
  14. data/app/controllers/lab/users_controller.rb +32 -0
  15. data/app/jobs/lab/application_job.rb +4 -0
  16. data/app/jobs/lab/push_order_job.rb +12 -0
  17. data/app/jobs/lab/update_patient_orders_job.rb +32 -0
  18. data/app/jobs/lab/void_order_job.rb +17 -0
  19. data/app/mailers/lab/application_mailer.rb +6 -0
  20. data/app/models/lab/application_record.rb +5 -0
  21. data/app/models/lab/lab_accession_number_counter.rb +13 -0
  22. data/app/models/lab/lab_acknowledgement.rb +6 -0
  23. data/app/models/lab/lab_encounter.rb +7 -0
  24. data/app/models/lab/lab_order.rb +58 -0
  25. data/app/models/lab/lab_result.rb +31 -0
  26. data/app/models/lab/lab_test.rb +19 -0
  27. data/app/models/lab/lims_failed_import.rb +4 -0
  28. data/app/models/lab/lims_order_mapping.rb +10 -0
  29. data/app/models/lab/order_extension.rb +14 -0
  30. data/app/serializers/lab/lab_order_serializer.rb +56 -0
  31. data/app/serializers/lab/result_serializer.rb +36 -0
  32. data/app/serializers/lab/test_serializer.rb +52 -0
  33. data/app/services/lab/accession_number_service.rb +77 -0
  34. data/app/services/lab/acknowledgement_service.rb +47 -0
  35. data/app/services/lab/concepts_service.rb +82 -0
  36. data/app/services/lab/json_web_token_service.rb +20 -0
  37. data/app/services/lab/labelling_service/order_label.rb +106 -0
  38. data/app/services/lab/lims/acknowledgement_serializer.rb +29 -0
  39. data/app/services/lab/lims/acknowledgement_worker.rb +37 -0
  40. data/app/services/lab/lims/api/blackhole_api.rb +21 -0
  41. data/app/services/lab/lims/api/couchdb_api.rb +53 -0
  42. data/app/services/lab/lims/api/mysql_api.rb +316 -0
  43. data/app/services/lab/lims/api/rest_api.rb +434 -0
  44. data/app/services/lab/lims/api/ws_api.rb +121 -0
  45. data/app/services/lab/lims/api_factory.rb +19 -0
  46. data/app/services/lab/lims/config.rb +105 -0
  47. data/app/services/lab/lims/exceptions.rb +11 -0
  48. data/app/services/lab/lims/migrator.rb +216 -0
  49. data/app/services/lab/lims/order_dto.rb +105 -0
  50. data/app/services/lab/lims/order_serializer.rb +251 -0
  51. data/app/services/lab/lims/pull_worker.rb +314 -0
  52. data/app/services/lab/lims/push_worker.rb +152 -0
  53. data/app/services/lab/lims/utils.rb +91 -0
  54. data/app/services/lab/lims/worker.rb +94 -0
  55. data/app/services/lab/metadata.rb +26 -0
  56. data/app/services/lab/notification_service.rb +72 -0
  57. data/app/services/lab/orders_search_service.rb +72 -0
  58. data/app/services/lab/orders_service.rb +330 -0
  59. data/app/services/lab/results_service.rb +166 -0
  60. data/app/services/lab/tests_service.rb +105 -0
  61. data/app/services/lab/user_service.rb +62 -0
  62. data/config/routes.rb +28 -0
  63. data/db/migrate/20210126092910_create_lab_lab_accession_number_counters.rb +12 -0
  64. data/db/migrate/20210310115457_create_lab_lims_order_mappings.rb +15 -0
  65. data/db/migrate/20210323080140_change_lims_id_to_string_in_lims_order_mapping.rb +15 -0
  66. data/db/migrate/20210326195504_add_order_revision_to_lims_order_mapping.rb +5 -0
  67. data/db/migrate/20210407071728_create_lab_lims_failed_imports.rb +19 -0
  68. data/db/migrate/20210610095024_fix_numeric_results_value_type.rb +20 -0
  69. data/db/migrate/20210807111531_add_default_to_lims_order_mapping.rb +7 -0
  70. data/lib/auto12epl.rb +201 -0
  71. data/lib/couch_bum/couch_bum.rb +92 -0
  72. data/lib/generators/lab/install/USAGE +9 -0
  73. data/lib/generators/lab/install/install_generator.rb +19 -0
  74. data/lib/generators/lab/install/templates/rswag-ui-lab.rb +5 -0
  75. data/lib/generators/lab/install/templates/start_worker.rb +32 -0
  76. data/lib/generators/lab/install/templates/swagger.yaml +714 -0
  77. data/lib/lab/engine.rb +13 -0
  78. data/lib/lab/version.rb +5 -0
  79. data/lib/logger_multiplexor.rb +38 -0
  80. data/lib/mahis_emr_api_lab.rb +6 -0
  81. data/lib/tasks/lab_tasks.rake +25 -0
  82. data/lib/tasks/loaders/data/reasons-for-test.csv +7 -0
  83. data/lib/tasks/loaders/data/test-measures.csv +225 -0
  84. data/lib/tasks/loaders/data/tests.csv +161 -0
  85. data/lib/tasks/loaders/loader_mixin.rb +53 -0
  86. data/lib/tasks/loaders/metadata_loader.rb +26 -0
  87. data/lib/tasks/loaders/reasons_for_test_loader.rb +23 -0
  88. data/lib/tasks/loaders/specimens_loader.rb +65 -0
  89. data/lib/tasks/loaders/test_result_indicators_loader.rb +54 -0
  90. metadata +331 -0
@@ -0,0 +1,434 @@
1
+ # frozen_string_literal: true
2
+ require 'rest-client'
3
+
4
+ class Lab::Lims::Api::RestApi
5
+ class LimsApiError < StandardError; end
6
+
7
+ class AuthenticationTokenExpired < LimsApiError; end
8
+
9
+ class InvalidParameters < LimsApiError; end
10
+
11
+ def initialize(config)
12
+ @config = config
13
+ end
14
+
15
+ def create_order(order_dto)
16
+ response = in_authenticated_session do |headers|
17
+ Rails.logger.info("Pushing order ##{order_dto[:tracking_number]} to LIMS")
18
+
19
+ if order_dto['sample_type'].casecmp?('not_specified')
20
+ RestClient.post(expand_uri('request_order', api_version: 'v2'), make_create_params(order_dto), headers)
21
+ else
22
+ RestClient.post(expand_uri('create_order'), make_create_params(order_dto), headers)
23
+ end
24
+ end
25
+
26
+ data = JSON.parse(response.body)
27
+ update_order_results(order_dto) unless data['message'].casecmp?('Order already available')
28
+
29
+ ActiveSupport::HashWithIndifferentAccess.new(
30
+ id: order_dto.fetch(:_id, order_dto[:tracking_number]),
31
+ rev: 0,
32
+ tracking_number: order_dto[:tracking_number]
33
+ )
34
+ end
35
+
36
+ def acknowledge(acknowledgement_dto)
37
+ Rails.logger.info("Acknowledging order ##{acknowledgement_dto} in LIMS")
38
+ response = in_authenticated_session do |headers|
39
+ RestClient.post(expand_uri('/acknowledge/test/results/recipient'), acknowledgement_dto, headers)
40
+ end
41
+ Rails.logger.info("Acknowledged order ##{acknowledgement_dto} in LIMS. Response: #{response}")
42
+ JSON.parse(response)
43
+ end
44
+
45
+ def update_order(_id, order_dto)
46
+ in_authenticated_session do |headers|
47
+ RestClient.post(expand_uri('update_order'), make_update_params(order_dto), headers)
48
+ end
49
+
50
+ update_order_results(order_dto)
51
+
52
+ { tracking_number: order_dto[:tracking_number] }
53
+ end
54
+
55
+ def consume_orders(*_args, patient_id: nil, **_kwargs)
56
+ orders_pending_updates(patient_id).each do |order|
57
+ order_dto = Lab::Lims::OrderSerializer.serialize_order(order)
58
+
59
+ if order_dto['priority'].nil? || order_dto['sample_type'].casecmp?('not_specified')
60
+ patch_order_dto_with_lims_order!(order_dto, find_lims_order(order.accession_number))
61
+ end
62
+
63
+ if order_dto['test_results'].empty?
64
+ begin
65
+ patch_order_dto_with_lims_results!(order_dto, find_lims_results(order.accession_number))
66
+ rescue InvalidParameters => e # LIMS responds with a 401 when a result is not found :(
67
+ Rails.logger.error("Failed to fetch results for ##{order.accession_number}: #{e.message}")
68
+ end
69
+ end
70
+
71
+ yield order_dto, OpenStruct.new(last_seq: 0)
72
+ rescue LimsApiError => e
73
+ Rails.logger.error("Failed to fetch updates for ##{order.accession_number}: #{e.class} - #{e.message}")
74
+ sleep(1)
75
+ end
76
+ end
77
+
78
+ def delete_order(_id, order_dto)
79
+ tracking_number = order_dto.fetch('tracking_number')
80
+
81
+ order_dto['tests'].each do |test|
82
+ Rails.logger.info("Voiding test '#{test}' (#{tracking_number}) in LIMS")
83
+ in_authenticated_session do |headers|
84
+ date_voided, voided_status = find_test_status(order_dto, test, 'Voided')
85
+ params = make_void_test_params(tracking_number, test, voided_status['updated_by'], date_voided)
86
+ RestClient.post(expand_uri('update_test'), params, headers)
87
+ end
88
+ end
89
+ end
90
+
91
+ def verify_tracking_number(tracking_number)
92
+ find_lims_order(tracking_number)
93
+ rescue InvalidParameters => e
94
+ Rails.logger.error("Failed to verify tracking number #{tracking_number}: #{e.message}")
95
+ false
96
+ end
97
+
98
+ private
99
+
100
+ attr_reader :config
101
+
102
+ MAX_LIMS_RETRIES = 5 # LIMS API Calls can only fail this number of times before we give up on it
103
+
104
+ ##
105
+ # Execute LIMS API calls within an authenticated session.
106
+ #
107
+ # Method automatically checks authenticates with LIMS if necessary and passes
108
+ # down the necessary headers for authentication to the REST call being made.
109
+ #
110
+ # Example:
111
+ #
112
+ # response = in_authenticated_session do |headers|
113
+ # RestClient.get(expand_uri('query_results_by_tracking_number/XXXXXX'), headers)
114
+ # end
115
+ #
116
+ # pp JSON.parse(response.body) if response.code == 200
117
+ def in_authenticated_session
118
+ retries ||= MAX_LIMS_RETRIES
119
+
120
+ self.authentication_token = authenticate unless authentication_token
121
+
122
+ response = yield 'token' => authentication_token, 'Content-type' => 'application/json'
123
+ check_response!(response)
124
+ rescue AuthenticationTokenExpired => e
125
+ self.authentication_token = nil
126
+ retry if (retries -= 1).positive?
127
+ rescue RestClient::ExceptionWithResponse => e
128
+ Rails.logger.error("LIMS Error: #{e.response&.code} - #{e.response&.body}")
129
+ raise e unless e.response&.code == 401
130
+
131
+ self.authentication_token = nil
132
+ retry if (retries -= 1).positive?
133
+ end
134
+
135
+ def authenticate
136
+ username = config.fetch(:username)
137
+ password = config.fetch(:password)
138
+
139
+ Rails.logger.debug("Authenticating with LIMS as: #{username}")
140
+ response = RestClient.get(expand_uri("re_authenticate/#{username}/#{password}"),
141
+ headers: { 'Content-type' => 'application/json' })
142
+ response_body = JSON.parse(response.body)
143
+
144
+ if response_body['status'] == 401
145
+ Rails.logger.error("Failed to authenticate with LIMS as #{config.fetch(:username)}: #{response_body['message']}")
146
+ raise LimsApiError, 'LIMS authentication failed'
147
+ end
148
+
149
+ response_body['data']['token']
150
+ end
151
+
152
+ def authentication_token=(token)
153
+ Thread.current[:lims_authentication_token] = token
154
+ end
155
+
156
+ def authentication_token
157
+ Thread.current[:lims_authentication_token]
158
+ end
159
+
160
+ ##
161
+ # Examines a response from LIMS to check if token has expired.
162
+ #
163
+ # LIMS' doesn't properly use HTTP status codes; the codes are embedded in the
164
+ # response body. 200 is used for success responses and 401 for everything else.
165
+ # We have this work around to examine the response body and
166
+ # throw errors accordingly. The following are the errors thrown:
167
+ #
168
+ # Lims::AuthenticationTokenExpired
169
+ # Lims::InvalidParameters
170
+ # Lims::ApiError - Thrown when we couldn't make sense of the error
171
+ def check_response!(response)
172
+ body = JSON.parse(response.body)
173
+ return response if body['status'] == 200
174
+
175
+ Rails.logger.error("Lims Api Error: #{response.body}")
176
+
177
+ raise LimsApiError, "#{body['status']} - #{body['message']}" if body['status'] != 401
178
+
179
+ if body['message'].match?(/token expired/i)
180
+ raise AuthenticationTokenExpired, "Authentication token expired: #{body['message']}"
181
+ end
182
+
183
+ raise InvalidParameters, body['message']
184
+ end
185
+
186
+ ##
187
+ # Takes a LIMS API relative URI and converts it to a full URL.
188
+ def expand_uri(uri, api_version: 'v1')
189
+ protocol = config.fetch(:protocol)
190
+ host = config.fetch(:host)
191
+ port = config.fetch(:port)
192
+ uri = uri.gsub(%r{^/+}, '')
193
+
194
+ "#{protocol}://#{host}:#{port}/api/#{api_version}/#{uri}"
195
+ end
196
+
197
+ ##
198
+ # Converts an OrderDTO to parameters for POST /create_order
199
+ def make_create_params(order_dto)
200
+ {
201
+ tracking_number: order_dto.fetch(:tracking_number),
202
+ district: current_district,
203
+ health_facility_name: order_dto.fetch(:sending_facility),
204
+ first_name: order_dto.fetch(:patient).fetch(:first_name),
205
+ last_name: order_dto.fetch(:patient).fetch(:last_name),
206
+ phone_number: order_dto.fetch(:patient).fetch(:phone_number),
207
+ gender: order_dto.fetch(:patient).fetch(:gender),
208
+ arv_number: order_dto.fetch(:patient).fetch(:arv_number),
209
+ art_regimen: order_dto.fetch(:patient).fetch(:art_regimen),
210
+ art_start_date: order_dto.fetch(:patient).fetch(:art_start_date),
211
+ date_of_birth: order_dto.fetch(:patient).fetch(:dob),
212
+ national_patient_id: order_dto.fetch(:patient).fetch(:id),
213
+ requesting_clinician: requesting_clinician(order_dto),
214
+ sample_type: order_dto.fetch(:sample_type),
215
+ tests: order_dto.fetch(:tests),
216
+ date_sample_drawn: sample_drawn_date(order_dto),
217
+ sample_priority: order_dto.fetch(:priority) || 'Routine',
218
+ sample_status: order_dto.fetch(:sample_status),
219
+ target_lab: order_dto.fetch(:receiving_facility),
220
+ order_location: order_dto.fetch(:order_location) || 'Unknown',
221
+ who_order_test_first_name: order_dto.fetch(:who_order_test).fetch(:first_name),
222
+ who_order_test_last_name: order_dto.fetch(:who_order_test).fetch(:last_name)
223
+ }
224
+ end
225
+
226
+ ##
227
+ # Converts an OrderDTO to parameters for POST /update_order
228
+ def make_update_params(order_dto)
229
+ date_updated, status = sample_drawn_status(order_dto)
230
+
231
+ {
232
+ tracking_number: order_dto.fetch(:tracking_number),
233
+ who_updated: status.fetch(:updated_by),
234
+ date_updated: date_updated,
235
+ specimen_type: order_dto.fetch(:sample_type),
236
+ status: 'specimen_collected'
237
+ }
238
+ end
239
+
240
+ def current_district
241
+ health_centre = Location.current_health_center
242
+ raise 'Current health centre not set' unless health_centre
243
+
244
+ district = health_centre.district || Lab::Lims::Config.application['district']
245
+
246
+ unless district
247
+ health_centre_name = "##{health_centre.id} - #{health_centre.name}"
248
+ raise "Current health centre district not set: #{health_centre_name}"
249
+ end
250
+
251
+ district
252
+ end
253
+
254
+ ##
255
+ # Extracts sample drawn status from an OrderDTO
256
+ def sample_drawn_status(order_dto)
257
+ order_dto[:sample_statuses].each do |trail_entry|
258
+ date, status = trail_entry.each_pair.find { |_date, status| status['status'].casecmp?('Drawn') }
259
+ next unless date
260
+
261
+ return Date.strptime(date, '%Y%m%d%H%M%S').strftime('%Y-%m-%d'), status
262
+ end
263
+
264
+ [order_dto['date_created'], nil]
265
+ end
266
+
267
+ ##
268
+ # Extracts a sample drawn date from a LIMS OrderDTO.
269
+ def sample_drawn_date(order_dto)
270
+ sample_drawn_status(order_dto).first
271
+ end
272
+
273
+ ##
274
+ # Extracts the requesting clinician from a LIMS OrderDTO
275
+ def requesting_clinician(order_dto)
276
+ orderer = order_dto[:who_order_test]
277
+
278
+ "#{orderer[:first_name]} #{orderer[:last_name]}"
279
+ end
280
+
281
+ def find_lims_order(tracking_number)
282
+ response = in_authenticated_session do |headers|
283
+ Rails.logger.info("Fetching order ##{tracking_number}")
284
+ RestClient.get(expand_uri("query_order_by_tracking_number/#{tracking_number}"), headers)
285
+ end
286
+
287
+ Rails.logger.info("Order ##{tracking_number} found... Parsing...")
288
+ JSON.parse(response).fetch('data')
289
+ end
290
+
291
+ def find_lims_results(tracking_number)
292
+ response = in_authenticated_session do |headers|
293
+ Rails.logger.info("Fetching results for order ##{tracking_number}")
294
+ RestClient.get(expand_uri("query_results_by_tracking_number/#{tracking_number}"), headers)
295
+ end
296
+
297
+ Rails.logger.info("Result for order ##{tracking_number} found... Parsing...")
298
+ JSON.parse(response).fetch('data').fetch('results')
299
+ end
300
+
301
+ ##
302
+ # Make a copy of the order_dto with the results from LIMS parsed
303
+ # and appended to it.
304
+ def patch_order_dto_with_lims_results!(order_dto, results)
305
+ order_dto.merge!(
306
+ '_id' => order_dto[:tracking_number],
307
+ '_rev' => 0,
308
+ 'test_results' => results.each_with_object({}) do |result, formatted_results|
309
+ test_name, measures = result
310
+ result_date = measures.delete('result_date')
311
+
312
+ formatted_results[test_name] = {
313
+ results: measures.each_with_object({}) do |measure, processed_measures|
314
+ processed_measures[measure[0]] = { 'result_value' => measure[1] }
315
+ end,
316
+ result_date: result_date,
317
+ result_entered_by: {}
318
+ }
319
+ end
320
+ )
321
+ end
322
+
323
+ def patch_order_dto_with_lims_order!(order_dto, lims_order)
324
+ order_dto.merge!(
325
+ 'sample_type' => lims_order['other']['sample_type'],
326
+ 'sample_status' => lims_order['other']['specimen_status'],
327
+ 'priority' => lims_order['other']['priority']
328
+ )
329
+ end
330
+
331
+ def update_order_results(order_dto)
332
+ return nil if order_dto['test_results'].nil? || order_dto['test_results'].empty?
333
+
334
+ order_dto['test_results'].each do |test_name, results|
335
+ Rails.logger.info("Pushing result for order ##{order_dto['tracking_number']}")
336
+ in_authenticated_session do |headers|
337
+ params = make_update_test_params(order_dto['tracking_number'], test_name, results)
338
+
339
+ RestClient.post(expand_uri('update_test'), params, headers)
340
+ end
341
+ end
342
+ end
343
+
344
+ def make_update_test_params(tracking_number, test_name, results, test_status = 'Drawn')
345
+ {
346
+ tracking_number: tracking_number,
347
+ test_name: test_name,
348
+ result_date: results['result_date'],
349
+ time_updated: results['result_date'],
350
+ who_updated: {
351
+ first_name: results[:result_entered_by][:first_name],
352
+ last_name: results[:result_entered_by][:last_name],
353
+ id_number: results[:result_entered_by][:id]
354
+ },
355
+ test_status: test_status,
356
+ results: results['results']&.each_with_object({}) do |measure, formatted_results|
357
+ measure_name, measure_value = measure
358
+
359
+ formatted_results[measure_name] = measure_value['result_value']
360
+ end
361
+ }
362
+ end
363
+
364
+ def find_test_status(order_dto, target_test, target_status)
365
+ order_dto['test_statuses'].each do |test, statuses|
366
+ next unless test.casecmp?(target_test)
367
+
368
+ statuses.each do |date, status|
369
+ next unless status['status'].casecmp?(target_status)
370
+
371
+ return [Date.strptime(date, '%Y%m%d%H%M%S'), status]
372
+ end
373
+ end
374
+
375
+ nil
376
+ end
377
+
378
+ def make_void_test_params(tracking_number, test_name, voided_by, void_date = nil)
379
+ void_date ||= Time.now
380
+
381
+ {
382
+ tracking_number: tracking_number,
383
+ test_name: test_name,
384
+ time_updated: void_date,
385
+ who_updated: {
386
+ first_name: voided_by[:first_name],
387
+ last_name: voided_by[:last_name],
388
+ id_number: voided_by[:id]
389
+ },
390
+ test_status: 'voided'
391
+ }
392
+ end
393
+
394
+ def orders_pending_updates(patient_id = nil)
395
+ Rails.logger.info('Looking for orders that need to be updated...')
396
+ orders = {}
397
+
398
+ orders_without_specimen(patient_id).each { |order| orders[order.order_id] = order }
399
+ orders_without_results(patient_id).each { |order| orders[order.order_id] = order }
400
+ orders_without_reason(patient_id).each { |order| orders[order.order_id] = order }
401
+
402
+ orders.values
403
+ end
404
+
405
+ def orders_without_specimen(patient_id = nil)
406
+ Rails.logger.debug('Looking for orders without a specimen')
407
+ unknown_specimen = ConceptName.where(name: Lab::Metadata::UNKNOWN_SPECIMEN)
408
+ .select(:concept_id)
409
+ orders = Lab::LabOrder.where(concept_id: unknown_specimen)
410
+ .where.not(accession_number: Lab::LimsOrderMapping.select(:lims_id))
411
+ orders = orders.where(patient_id: patient_id) if patient_id
412
+
413
+ orders
414
+ end
415
+
416
+ def orders_without_results(patient_id = nil)
417
+ Rails.logger.debug('Looking for orders without a result')
418
+ # Lab::OrdersSearchService.find_orders_without_results(patient_id: patient_id)
419
+ # .where.not(accession_number: Lab::LimsOrderMapping.select(:lims_id).where("pulled_at IS NULL"))
420
+ Lab::OrdersSearchService.find_orders_without_results(patient_id: patient_id)
421
+ .where(order_id: Lab::LimsOrderMapping.select(:order_id))
422
+ end
423
+
424
+ def orders_without_reason(patient_id = nil)
425
+ Rails.logger.debug('Looking for orders without a reason for test')
426
+ orders = Lab::LabOrder.joins(:reason_for_test)
427
+ .merge(Observation.where(value_coded: nil, value_text: nil))
428
+ .limit(1000)
429
+ .where.not(accession_number: Lab::LimsOrderMapping.select(:lims_id))
430
+ orders = orders.where(patient_id: patient_id) if patient_id
431
+
432
+ orders
433
+ end
434
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket.io-client-simple'
4
+
5
+ module Lab
6
+ module Lims
7
+ module Api
8
+ ##
9
+ # Retrieve results from LIMS only through a websocket
10
+ class WsApi
11
+ def initialize(config)
12
+ @config = config
13
+ @results_queue = []
14
+ @socket = nil
15
+ end
16
+
17
+ def consume_orders(**_kwargs)
18
+ loop do
19
+ results = fetch_results
20
+ unless results
21
+ Rails.logger.debug('No results available... Waiting for results...')
22
+ sleep(Lab::Lims::Config.updates_poll_frequency)
23
+ next
24
+ end
25
+
26
+ Rails.logger.info("Received result for ##{results['tracking_number']}")
27
+ order = find_order(results['tracking_number'])
28
+ next unless order
29
+
30
+ Rails.logger.info("Updating result for order ##{order.order_id}")
31
+ yield make_order_dto(order, results), OpenStruct.new(last_seq: 1)
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def initialize_socket
38
+ Rails.logger.debug('Establishing connection to socket...')
39
+ socket = SocketIO::Client::Simple.connect(socket_url)
40
+ socket.on(:connect, &method(:on_socket_connect))
41
+ socket.on(:disconnect, &method(:on_socket_disconnect))
42
+ socket.on(:results, &method(:on_results_received))
43
+ end
44
+
45
+ def socket_url
46
+ @config.fetch('url')
47
+ end
48
+
49
+ def on_socket_connect
50
+ Rails.logger.debug('Connection to LIMS results socket established...')
51
+ end
52
+
53
+ def on_socket_disconnect
54
+ Rails.logger.debug('Connection to LIMS results socket lost...')
55
+ @socket = nil
56
+ end
57
+
58
+ def on_results_received(result)
59
+ Rails.logger.debug("Received result from LIMS: #{result}")
60
+ tracking_number = result['tracking_number']
61
+
62
+ Rails.logger.debug("Queueing result for order ##{tracking_number}")
63
+ @results_queue.push(result)
64
+ end
65
+
66
+ def order_exists?(tracking_number)
67
+ Rails.logger.debug("Looking for order for result ##{tracking_number}")
68
+ orders = OrdersSearchService.find_orders_without_results
69
+ .where(accession_number: tracking_number)
70
+ # The following ensures that the order was previously pushed to LIMS
71
+ # or was received from LIMS
72
+ Lab::LimsOrderMapping.where.not(order: orders).exists?
73
+ end
74
+
75
+ def fetch_results
76
+ loop do
77
+ @socket ||= initialize_socket
78
+
79
+ results = @results_queue.shift
80
+ return nil unless results
81
+
82
+ unless order_exists?(results['tracking_number'])
83
+ Rails.logger.debug("Ignoring result for order ##{tracking_number}")
84
+ next
85
+ end
86
+
87
+ return results
88
+ end
89
+ end
90
+
91
+ def find_order(lims_id)
92
+ mapping = Lab::LimsOrderMapping.where(lims_id: lims_id).select(:order_id)
93
+ Lab::LabOrder.find_by(order_id: mapping)
94
+ end
95
+
96
+ def make_order_dto(order, results)
97
+ Lab::Lims::OrderSerializer
98
+ .serialize_order(order)
99
+ .merge(
100
+ id: order.accession_number,
101
+ test_results: {
102
+ results['test_name'] => {
103
+ results: results['results'].each_with_object({}) do |measure, formatted_measures|
104
+ measure_name, measure_value = measure
105
+
106
+ formatted_measures[measure_name] = { result_value: measure_value }
107
+ end,
108
+ result_date: results['date_updated'],
109
+ result_entered_by: {
110
+ first_name: results['who_updated']['first_name'],
111
+ last_name: results['who_updated']['last_name'],
112
+ id: results['who_updated']['id_number']
113
+ }
114
+ }
115
+ }
116
+ )
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lab
4
+ module Lims
5
+ ##
6
+ # Creates LIMS Apis based on current configuration
7
+ module ApiFactory
8
+ def self.create_api
9
+ return Lab::Lims::Api::BlackholeApi.new if Rails.env.casecmp?('test')
10
+
11
+ case Lab::Lims::Config.preferred_api
12
+ when /rest/i then Lab::Lims::Api::RestApi.new(Lab::Lims::Config.rest_api)
13
+ when /couchdb/ then Lab::Lims::Api::CouchDbApi.new(config: Lab::Lims::Config.couchdb_api)
14
+ else raise "Invalid lims_api configuration: #{Lab::Lims::Config.preferred_api}"
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lab
4
+ module Lims
5
+ ##
6
+ # Load LIMS' configuration files
7
+ module Config
8
+ # TODO: Replace this maybe with `Rails.application.configuration.lab.lims`
9
+ # so that we do not have to directly mess with configuration files.
10
+
11
+ class ConfigNotFound < RuntimeError; end
12
+
13
+ class << self
14
+ def preferred_api
15
+ emr_api_application('lims_api')
16
+ end
17
+
18
+ ##
19
+ # Returns LIMS' couchdb configuration file for the current environment (Rails.env)
20
+ def couchdb
21
+ config_path = begin
22
+ find_config_path('couchdb.yml')
23
+ rescue ConfigNotFound => e
24
+ Rails.logger.error("Failed to find default LIMS couchdb config: #{e.message}")
25
+ find_config_path('couchdb-lims.yml') # This can be placed in HIS-EMR-API/config
26
+ end
27
+
28
+ Rails.logger.debug("Using LIMS couchdb config: #{config_path}")
29
+
30
+ YAML.load_file(config_path)[Rails.env]
31
+ end
32
+
33
+ def rest_api
34
+ @rest_api ||= {
35
+ protocol: emr_api_application('lims_protocol', 'http'),
36
+ host: emr_api_application('lims_host'),
37
+ port: emr_api_application('lims_port'),
38
+ username: emr_api_application('lims_username'),
39
+ password: emr_api_application('lims_password')
40
+ }
41
+ end
42
+
43
+ def updates_socket
44
+ @updates_socket ||= {
45
+ 'url' => emr_api_application('lims_realtime_updates_url')
46
+ }
47
+ end
48
+
49
+ def updates_poll_frequency
50
+ 30 # Seconds
51
+ end
52
+
53
+ def config_file_name
54
+ # if nlims.yml exists return it, else return application.yml
55
+ File.exist?(Rails.root.join('config', 'nlims.yml')) ? 'nlims.yml' : 'application.yml'
56
+ end
57
+
58
+ ##
59
+ # Returns LIMS' application.yml configuration file
60
+ def application
61
+ @application ||= YAML.load_file(find_config_path(config_file_name))
62
+ end
63
+
64
+ ##
65
+ # Returns LIMS' database.yml configuration file
66
+ def database
67
+ @database ||= YAML.load_file(find_config_path('database.yml'))
68
+ end
69
+
70
+ private
71
+
72
+ def emr_api_application(param, fallback = nil)
73
+ @emr_api_application ||= YAML.load_file(Rails.root.join('config', config_file_name))
74
+
75
+ @emr_api_application.fetch(param) do
76
+ raise ConfigNotFound, "Missing config param: #{param}" unless fallback
77
+
78
+ fallback
79
+ end
80
+ end
81
+
82
+ ##
83
+ # Looks for a config file in various LIMS installation directories
84
+ #
85
+ # Returns: a path to a file found
86
+ def find_config_path(filename)
87
+ paths = [
88
+ "#{Dir.home}/apps/nlims_controller/config/#{filename}",
89
+ "/var/www/nlims_controller/config/#{filename}",
90
+ Rails.root.parent.join("nlims_controller/config/#{filename}")
91
+ ]
92
+
93
+ paths = [Rails.root.join('config/lims-couchdb.yml'), *paths] if filename == 'couchdb.yml'
94
+
95
+ paths.each do |path|
96
+ Rails.logger.debug("Looking for LIMS couchdb config at: #{path}")
97
+ return path if File.exist?(path)
98
+ end
99
+
100
+ raise ConfigNotFound, "Could not find a configuration file, checked: #{paths.join(':')}"
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lab
4
+ module Lims
5
+ class LimsException < StandardError; end
6
+ class DuplicateNHID < LimsException; end
7
+ class MissingAccessionNumber < LimsException; end
8
+ class UnknownSpecimenType < LimsException; end
9
+ class UnknownTestType < LimsException; end
10
+ end
11
+ end