his_emr_api_lab 0.0.3 → 0.0.8

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: 778184a052cac6932ec700f03b99615024440af3944c371d0cfa997f357c525c
4
- data.tar.gz: 045fb73daab53abc457c08152c5148251a6dccb239f0c76e09f3172007e44b6e
3
+ metadata.gz: 7b410e215fad55950c150d2ec7a82bda3f434cca4cc287e0d4fca2a9116e9182
4
+ data.tar.gz: 6b3a0c75c303c2139c1f6f963875667c4606bce42d23f6a22c277412e24db960
5
5
  SHA512:
6
- metadata.gz: 95f56b7a5d1b36565074904d49e5ff69b34fb57bed690a7bec5d269749c6813e094b4e02b397517523edd90e0714710376f489338788838fad775f3c724659db
7
- data.tar.gz: 380096a9132eb857a4a321260593640eec0d6a5b08f661e97a1ba2396619eb5984c1aca488e364930dff737ff397b07865597b9ed321c7a93101c151a8d2566f
6
+ metadata.gz: 4f6068aecbc02d5c3df75ac422c4d4f9baeac86c6559a1e75078ef68088b4e6702239a366fb5e5879a2925592e3440a2880668bcc50f4730c9d2051f522ac94a
7
+ data.tar.gz: 5e3b4d61f507f2de7847951dcdd738a50dd29ba4ae5f981ce8284f3ba11c35d6d1c337787c1b78154b96eb318fc5343d404a3827fd02c89ffc3da60eb2d617c3
@@ -2,6 +2,8 @@
2
2
 
3
3
  module Lab
4
4
  class LabelsController < ApplicationController
5
+ skip_before_action :authenticate
6
+
5
7
  def print_order_label
6
8
  order_id = params.require(:order_id)
7
9
 
@@ -27,7 +27,7 @@ module Lab
27
27
  '',
28
28
  drawer,
29
29
  '',
30
- specimen,
30
+ tests,
31
31
  reason_for_test,
32
32
  order.accession_number,
33
33
  order.accession_number)
@@ -36,7 +36,7 @@ module Lab
36
36
  def reason_for_test
37
37
  return 'Unknown' unless order.reason_for_test
38
38
 
39
- ConceptName.find_by_concept_id(order.reason_for_test.value_coded)&.name || 'Unknown'
39
+ short_concept_name(order.reason_for_test.value_coded) || 'Unknown'
40
40
  end
41
41
 
42
42
  def patient
@@ -73,6 +73,24 @@ module Lab
73
73
  ConceptName.find_by_concept_id(order.concept_id)&.name || 'Unknown'
74
74
  end
75
75
 
76
+ def tests
77
+ tests = order.tests.map do |test|
78
+ name = short_concept_name(test.value_coded) || 'Unknown'
79
+
80
+ next 'VL' if name.match?(/Viral load/i)
81
+
82
+ name.size > 7 ? name[0..6] : name
83
+ end
84
+
85
+ tests.join(', ')
86
+ end
87
+
88
+ def short_concept_name(concept_id)
89
+ ConceptName.where(concept_id: concept_id)
90
+ .min_by { |concept| concept.name.size }
91
+ &.name
92
+ end
93
+
76
94
  def unknown_concept
77
95
  ConceptName.find_by_name('Unknown')
78
96
  end
@@ -29,16 +29,11 @@ module Lab
29
29
  # given block until the queue is empty or connection is terminated
30
30
  # by calling method +choke+.
31
31
  def consume_orders(from: 0, limit: 30)
32
- last_seq = { value: 0 }
33
-
34
32
  bum.binge_changes(since: from, limit: limit, include_docs: true) do |change|
35
33
  next unless change['doc']['type']&.casecmp?('Order')
36
34
 
37
35
  yield OrderDTO.new(change['doc']), self
38
- last_seq[:value] = self.last_seq
39
36
  end
40
-
41
- last_seq[:value]
42
37
  end
43
38
 
44
39
  def create_order(order)
@@ -29,6 +29,7 @@ require 'lab/lab_test'
29
29
  require 'lab/lims_order_mapping'
30
30
  require 'lab/lims_failed_import'
31
31
 
32
+ require_relative './worker'
32
33
  require_relative '../orders_service'
33
34
  require_relative '../results_service'
34
35
  require_relative '../tests_service'
@@ -47,14 +48,16 @@ module Lab
47
48
 
48
49
  attr_reader :rejections
49
50
 
50
- def consume_orders(from: nil, limit: 50_000)
51
+ def consume_orders(from: nil, **_kwargs)
52
+ limit = 50_000
53
+
51
54
  Parallel.each(read_orders(from, limit),
52
55
  in_processes: MAX_THREADS,
53
56
  finish: order_pmap_post_processor(from)) do |row|
54
57
  next unless row['doc']['type']&.casecmp?('Order')
55
58
 
56
- User.current = Migrator.lab_user
57
- yield OrderDTO.new(row['doc']), OpenStruct.new(last_seq: from)
59
+ User.current = Utils.lab_user
60
+ yield OrderDTO.new(row['doc']), OpenStruct.new(last_seq: (from || 0) + limit, current_seq: from)
58
61
  end
59
62
  end
60
63
 
@@ -70,7 +73,7 @@ module Lab
70
73
  private
71
74
 
72
75
  def last_seq_path
73
- Rails.root.join('log/lims/migration-last-id.dat')
76
+ LIMS_LOG_PATH.join('migration-last-id.dat')
74
77
  end
75
78
 
76
79
  def order_pmap_post_processor(last_seq)
@@ -129,18 +132,6 @@ module Lab
129
132
  def update_last_seq(_last_seq); end
130
133
  end
131
134
 
132
- def self.lab_user
133
- user = User.find_by_username('lab_daemon')
134
- return user if user
135
-
136
- god_user = User.first
137
-
138
- person = Person.create!(creator: god_user.user_id)
139
- PersonName.create!(person: person, given_name: 'Lab', family_name: 'Daemon', creator: god_user.user_id)
140
-
141
- User.create!(username: 'lab_daemon', person: person, creator: god_user.user_id)
142
- end
143
-
144
135
  def self.save_csv(filename, rows:, headers: nil)
145
136
  CSV.open(filename, File::WRONLY | File::CREAT) do |csv|
146
137
  csv << headers if headers
@@ -148,12 +139,13 @@ module Lab
148
139
  end
149
140
  end
150
141
 
151
- MIGRATION_REJECTIONS_CSV_PATH = Rails.root.join('log/lims/migration-rejections.csv')
142
+ MIGRATION_REJECTIONS_CSV_PATH = LIMS_LOG_PATH.join('migration-rejections.csv')
152
143
 
153
144
  def self.export_rejections(rejections)
154
- headers = ['Accession number', 'NHID', 'First name', 'Last name', 'Reason']
145
+ headers = ['doc_id', 'Accession number', 'NHID', 'First name', 'Last name', 'Reason']
155
146
  rows = (rejections || []).map do |rejection|
156
147
  [
148
+ rejection.order[:_id],
157
149
  rejection.order[:tracking_number],
158
150
  rejection.order[:patient][:id],
159
151
  rejection.order[:patient][:first_name],
@@ -165,12 +157,13 @@ module Lab
165
157
  save_csv(MIGRATION_REJECTIONS_CSV_PATH, headers: headers, rows: rows)
166
158
  end
167
159
 
168
- MIGRATION_FAILURES_CSV_PATH = Rails.root.join('log/lims/migration-failures.csv')
160
+ MIGRATION_FAILURES_CSV_PATH = LIMS_LOG_PATH.join('migration-failures.csv')
169
161
 
170
162
  def self.export_failures
171
- headers = ['Accession number', 'NHID', 'Reason', 'Difference']
163
+ headers = ['doc_id', 'Accession number', 'NHID', 'Reason', 'Difference']
172
164
  rows = Lab::LimsFailedImport.all.map do |failure|
173
165
  [
166
+ failure.lims_id,
174
167
  failure.tracking_number,
175
168
  failure.patient_nhid,
176
169
  failure.reason,
@@ -181,7 +174,7 @@ module Lab
181
174
  save_csv(MIGRATION_FAILURES_CSV_PATH, headers: headers, rows: rows)
182
175
  end
183
176
 
184
- MIGRATION_LOG_PATH = Rails.root.join('log/lims/migration.log')
177
+ MIGRATION_LOG_PATH = LIMS_LOG_PATH.join('migration.log')
185
178
 
186
179
  def self.start_migration
187
180
  log_dir = Rails.root.join('log/lims')
@@ -30,9 +30,9 @@ module Lab
30
30
 
31
31
  # Translates a LIMS specimen name to an OpenMRS concept_id
32
32
  def specimen_type_id
33
- lims_specimen_name = self['sample_type']
33
+ lims_specimen_name = self['sample_type']&.strip&.downcase
34
34
 
35
- if %w[specimen_not_collected not_assigned].include?(lims_specimen_name)
35
+ if %w[specimen_not_collected not_assigned not_specified].include?(lims_specimen_name)
36
36
  return ConceptName.select(:concept_id).find_by_name!('Unknown').concept_id
37
37
  end
38
38
 
@@ -53,6 +53,8 @@ module Lab
53
53
 
54
54
  # Extract requesting clinician name from LIMS
55
55
  def requesting_clinician
56
+ return 'Unknown' unless self['who_order_test']
57
+
56
58
  # TODO: Extend requesting clinician to an obs tree having extra parameters
57
59
  # like phone number and ID to closely match the lims user.
58
60
  first_name = self['who_order_test']['first_name'] || ''
@@ -97,7 +97,9 @@ module Lab
97
97
  end
98
98
 
99
99
  def format_test_results(order)
100
- order.tests.each_with_object({}) do |test, results|
100
+ order.tests&.each_with_object({}) do |test, results|
101
+ next unless test.result
102
+
101
103
  results[test.name] = {
102
104
  results: test.result.each_with_object({}) do |measure, measures|
103
105
  measures[measure.indicator.name] = { result_value: "#{measure.value_modifier}#{measure.value}" }
@@ -39,6 +39,18 @@ module Lab
39
39
  end
40
40
  end
41
41
 
42
+ def self.lab_user
43
+ user = User.find_by_username('lab_daemon')
44
+ return user if user
45
+
46
+ god_user = User.first
47
+
48
+ person = Person.create!(creator: god_user.user_id)
49
+ PersonName.create!(person: person, given_name: 'Lab', family_name: 'Daemon', creator: god_user.user_id)
50
+
51
+ User.create!(username: 'lab_daemon', person: person, creator: god_user.user_id)
52
+ end
53
+
42
54
  def self.parse_date(str_date, fallback_date = nil)
43
55
  if str_date.blank? && fallback_date.blank?
44
56
  raise "Can't parse blank date"
@@ -2,12 +2,16 @@
2
2
 
3
3
  require 'cgi/util'
4
4
 
5
+ require_relative './api'
5
6
  require_relative './exceptions'
6
7
  require_relative './order_serializer'
7
8
  require_relative './utils'
8
9
 
9
10
  module Lab
10
11
  module Lims
12
+ LIMS_LOG_PATH = Rails.root.join('log/lims')
13
+ Dir.mkdir(LIMS_LOG_PATH) unless File.exist?(LIMS_LOG_PATH)
14
+
11
15
  ##
12
16
  # Pull/Push orders from/to the LIMS queue (Oops meant CouchDB).
13
17
  class Worker
@@ -15,6 +19,21 @@ module Lab
15
19
 
16
20
  attr_reader :lims_api
17
21
 
22
+ def self.start
23
+ File.open(LIMS_LOG_PATH.join('worker.lock'), File::WRONLY | File::CREAT, 0o644) do |fout|
24
+ fout.flock(File::LOCK_EX)
25
+
26
+ User.current = Utils.lab_user
27
+
28
+ fout.write("Worker ##{Process.pid} started at #{Time.now}")
29
+ worker = new(Api.new)
30
+ worker.pull_orders
31
+ # TODO: Verify that names being pushed to LIMS are of the correct format (ie matching
32
+ # LIMS naming conventions). Enable pushing when that is done
33
+ # worker.push_orders
34
+ end
35
+ end
36
+
18
37
  def initialize(lims_api)
19
38
  @lims_api = lims_api
20
39
  end
@@ -52,8 +71,8 @@ module Lab
52
71
  lims_api.update_order(mapping.lims_id, order_dto)
53
72
  mapping.update(pushed_at: Time.now)
54
73
  else
55
- order_dto = lims_api.create_order(order_dto)
56
- LimsOrderMapping.create!(order: order, lims_id: order_dto['_id'], pushed_at: Time.now)
74
+ update = lims_api.create_order(order_dto)
75
+ LimsOrderMapping.create!(order: order, lims_id: update['id'], revision: update['rev'], pushed_at: Time.now)
57
76
  end
58
77
  end
59
78
 
@@ -64,7 +83,8 @@ module Lab
64
83
  # Pulls orders from the LIMS queue and writes them to the local database
65
84
  def pull_orders
66
85
  logger.info("Retrieving LIMS orders starting from #{last_seq}")
67
- lims_api.consume_orders(from: last_seq) do |order_dto, context|
86
+
87
+ lims_api.consume_orders(from: last_seq, limit: 100) do |order_dto, context|
68
88
  logger.debug("Retrieved order ##{order_dto[:tracking_number]}: #{order_dto}")
69
89
 
70
90
  patient = find_patient_by_nhid(order_dto[:patient][:id])
@@ -80,6 +100,8 @@ module Lab
80
100
  save_failed_import(order_dto, 'Demographics not matching', diff)
81
101
  end
82
102
 
103
+ update_last_seq(context.current_seq)
104
+
83
105
  [:accepted, "Patient NPID, '#{order_dto[:patient][:id]}', matched"]
84
106
  rescue DuplicateNHID
85
107
  logger.warn("Failed to import order due to duplicate patient NHID: #{order_dto[:patient][:id]}")
@@ -90,8 +112,6 @@ module Lab
90
112
  rescue LimsException => e
91
113
  logger.warn("Failed to import order due to #{e.class} - #{e.message}")
92
114
  save_failed_import(order_dto, e.message)
93
- ensure
94
- update_last_seq(context.last_seq)
95
115
  end
96
116
  end
97
117
 
@@ -264,12 +284,12 @@ module Lab
264
284
  indicator: { concept_id: indicator.concept_id },
265
285
  value_type: value_type,
266
286
  value: value_type == 'numeric' ? value.to_f : value,
267
- value_modifier: value_modifier
287
+ value_modifier: value_modifier.blank? ? '=' : value_modifier
268
288
  )
269
289
  end
270
290
 
271
291
  def parse_lims_result_value(value)
272
- value = value['result_value']
292
+ value = value['result_value']&.strip
273
293
  return nil, nil, nil if value.blank?
274
294
 
275
295
  match = value&.match(/^(>|=|<|<=|>=)(.*)$/)
@@ -279,7 +299,7 @@ module Lab
279
299
  end
280
300
 
281
301
  def guess_result_datatype(result)
282
- return 'numeric' if result.match?(/^[+-]?(\d+(\.\d+)|\.\d+)?$/)
302
+ return 'numeric' if result.strip.match?(/^[+-]?(\d+(\.\d+)|\.\d+)?$/)
283
303
 
284
304
  'text'
285
305
  end
@@ -303,7 +323,7 @@ module Lab
303
323
  end
304
324
 
305
325
  def last_seq_path
306
- Rails.root.join('log/lims/last-seq.dat')
326
+ LIMS_LOG_PATH.join('last_seq.dat')
307
327
  end
308
328
  end
309
329
  end
@@ -5,44 +5,54 @@ module Lab
5
5
  module OrdersSearchService
6
6
  class << self
7
7
  def find_orders(filters)
8
- date = filters.delete(:date)
9
- status = filters.delete(:status)
8
+ extra_filters = pop_filters(filters, :date, :end_date, :status)
10
9
 
11
10
  orders = Lab::LabOrder.prefetch_relationships
12
11
  .where(filters)
13
12
  .order(start_date: :desc)
14
13
 
15
- orders = filter_orders_by_date(orders, date) if date
16
- orders = filter_orders_by_status(orders, status) if status
14
+ orders = filter_orders_by_status(orders, pop_filters(extra_filters, :status))
15
+ orders = filter_orders_by_date(orders, extra_filters)
17
16
 
18
17
  orders.map { |order| Lab::LabOrderSerializer.serialize_order(order) }
19
18
  end
20
19
 
21
- def filter_orders_by_date(orders, date)
22
- orders.where('start_date < DATE(?)', date.to_date + 1.day)
23
- end
20
+ def filter_orders_by_date(orders, date: nil, end_date: nil)
21
+ date = date&.to_date
22
+ end_date = end_date&.to_date
24
23
 
25
- def filter_orders_by_status(orders, status)
26
- case status.downcase
27
- when 'ordered' then orders.where(concept_id: unknown_concept_id)
28
- when 'drawn' then orders.where.not(concept_id: unknown_concept_id)
24
+ if date && end_date
25
+ return orders.where('start_date BETWEEN ? AND ?', date, end_date + 1.day)
29
26
  end
30
- end
31
27
 
32
- def unknown_concept_id
33
- ConceptName.find_by_name!('Unknown').concept_id
28
+ if date
29
+ return orders.where('start_date BETWEEN ? AND ?', date, date + 1.day)
30
+ end
31
+
32
+ return orders.where('start_date < ?', end_date + 1.day) if end_date
33
+
34
+ orders
34
35
  end
35
36
 
36
- def filter_orders_by_status(orders, status)
37
- case status.downcase
37
+ def filter_orders_by_status(orders, status: nil)
38
+ case status&.downcase
38
39
  when 'ordered' then orders.where(concept_id: unknown_concept_id)
39
40
  when 'drawn' then orders.where.not(concept_id: unknown_concept_id)
41
+ else orders
40
42
  end
41
43
  end
42
44
 
43
45
  def unknown_concept_id
44
46
  ConceptName.find_by_name!('Unknown').concept_id
45
47
  end
48
+
49
+ def pop_filters(params, *filters)
50
+ filters.each_with_object({}) do |filter, popped_params|
51
+ next unless params.key?(filter)
52
+
53
+ popped_params[filter.to_sym] = params.delete(filter)
54
+ end
55
+ end
46
56
  end
47
57
  end
48
58
  end
@@ -24,18 +24,21 @@ class CouchBum
24
24
  # within the passed block.
25
25
  def binge_changes(since: 0, limit: nil, include_docs: nil, &block)
26
26
  catch(:choke) do
27
- extra_params = stringify_params(limit: limit, include_docs: include_docs)
27
+ logger.debug("Binging #{limit} changes from '#{since}'")
28
+ params = stringify_params(limit: limit, include_docs: include_docs)
29
+ params = "since=#{since}&#{params}" unless since.blank?
28
30
 
29
- changes = couch_rest(:get, "_changes?since=#{since}&#{extra_params}")
31
+ changes = couch_rest(:get, "_changes?#{params}")
30
32
  context = BingeContext.new(changes)
31
- changes['results'].each { |change| context.instance_exec(change, &block) }
33
+ changes['results'].each do |change|
34
+ context.current_seq = change['seq']
35
+ context.instance_exec(change, &block)
36
+ end
32
37
  end
33
38
  end
34
39
 
35
40
  def couch_rest(method, route, *args, **kwargs)
36
41
  url = expand_route(route)
37
-
38
- logger.debug("CouchBum: Executing #{method} #{url}")
39
42
  CouchRest.send(method, url, *args, **kwargs)
40
43
  rescue CouchRest::Exception => e
41
44
  logger.error("Failed to communicate with CouchDB: Status: #{e.http_code} - #{e.http_body}")
@@ -46,6 +49,8 @@ class CouchBum
46
49
 
47
50
  # Context under which the callback passed to binge_changes is executed.
48
51
  class BingeContext
52
+ attr_accessor :current_seq
53
+
49
54
  def initialize(changes)
50
55
  @changes = changes
51
56
  end
@@ -175,6 +175,12 @@ paths:
175
175
  description: 'Filter by sample status: ordered, drawn'
176
176
  schema:
177
177
  type: string
178
+ - name: end_date
179
+ in: query
180
+ required: false
181
+ description: Select all results before this date
182
+ schema:
183
+ type: date
178
184
  responses:
179
185
  '200':
180
186
  description: Success
@@ -396,6 +402,32 @@ paths:
396
402
  responses:
397
403
  '204':
398
404
  description: No Content
405
+ "/api/v1/lab/reasons_for_test":
406
+ get:
407
+ summary: Reasons for test
408
+ description: Retrieve default reasons for test concept set
409
+ tags:
410
+ - Concepts
411
+ security:
412
+ - api_key: []
413
+ responses:
414
+ '200':
415
+ description: Success
416
+ content:
417
+ application/json:
418
+ schema:
419
+ type: array
420
+ items:
421
+ type: object
422
+ properties:
423
+ concept_id:
424
+ type: integer
425
+ name:
426
+ type: string
427
+ example: Routine
428
+ required:
429
+ - concept_id
430
+ - name
399
431
  "/api/v1/lab/tests/{test_id}/results":
400
432
  post:
401
433
  summary: Add results to order
data/lib/lab/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lab
4
- VERSION = '0.0.3'
4
+ VERSION = '0.0.8'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: his_emr_api_lab
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.3
4
+ version: 0.0.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Elizabeth Glaser Pediatric Foundation Malawi
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-04-18 00:00:00.000000000 Z
11
+ date: 2021-04-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: couchrest