openmrs_data_sanitizer 1.0.1

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.
@@ -0,0 +1,414 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'faker'
6
+ require 'fhir_models'
7
+ require 'concurrent-ruby'
8
+
9
+ # DataSanitizer
10
+ module OpenmrsDataSanitizer
11
+ # Error < StandardError; end
12
+
13
+ # rubocop:disable Metrics/ClassLength
14
+ # AWS DataSanitizer
15
+ class DataSanitizer
16
+ attr_accessor :fhir_encounters, :fhir_observations, :regimen_combinations,
17
+ :concept_names, :faker_names, :sites, :patients_json,
18
+ :fhir_medication_dispense
19
+
20
+ def initialize(sites: [])
21
+ @fhir_encounters = Concurrent::Array.new
22
+ @fhir_observations = Concurrent::Array.new
23
+ @fhir_medication_dispense = Concurrent::Array.new
24
+ @concept_names = Concurrent::Hash.new
25
+ @faker_names = Concurrent::Hash.new
26
+ @patients_json = Concurrent::Array.new
27
+ @regimen_combinations = {}
28
+ @sites = sites
29
+ load_regimen_combinations
30
+ end
31
+
32
+ def start
33
+ start_time = Time.now
34
+ Rails.logger.info("DataSanitizer started at #{start_time}")
35
+ clear_directory
36
+ process_data_request
37
+ end_time = Time.now
38
+ time_taken = (end_time - start_time) / 60
39
+ Rails.logger.info("DataSanitizer completed at #{end_time} and it took #{time_taken} minutes")
40
+ { start_time:, end_time:, time_in_minutes: (end_time - start_time) / 60 }
41
+ end
42
+
43
+ private
44
+
45
+ FILE_MUTEX = Mutex.new
46
+
47
+ # ===================================
48
+ # REGION: DATA PROCESSING
49
+ # ===================================
50
+
51
+ # rubocop:disable Metrics/MethodLength
52
+ def process_data_request
53
+ queue = Queue.new
54
+ sites.each { |loc| queue << loc }
55
+
56
+ threads = Array.new(10) do
57
+ Thread.new do
58
+ until queue.empty?
59
+ loc = begin
60
+ queue.pop(true)
61
+ rescue StandardError
62
+ nil
63
+ end
64
+ next unless loc
65
+
66
+ process_location(loc)
67
+ end
68
+ end
69
+ end
70
+
71
+ threads.each(&:join)
72
+ write_data_to_files
73
+ end
74
+
75
+ def process_location(loc)
76
+ ActiveRecord::Base.connection_pool.with_connection do
77
+ site = ::SchemaService.new.by_site(loc)&.first
78
+ return unless site
79
+
80
+ site_name = site['facility']
81
+ schema = site['schema']
82
+
83
+ OpenmrsDataSanitizer::DataAggregator.new(schema:).aggregate
84
+
85
+ process_patients(schema, site_name)
86
+ rescue StandardError => e
87
+ Rails.logger.info("Error processing location #{loc}: #{e.message}")
88
+ Rails.logger.info(e.backtrace.join("\n"))
89
+ end
90
+ end
91
+ # rubocop:enable Metrics/MethodLength
92
+
93
+ def process_patients(schema, site_name)
94
+ patients = fetch_patients(schema:)
95
+ patients_ids = patients.map { |patient| patient['person_id'].to_i }
96
+ patients.map { |patient| build_patient_json(patient, site_name) }
97
+
98
+ visits = fetch_visits(patients_ids:, schema:)
99
+ process_visits(visits:)
100
+ process_medication(schema:)
101
+ end
102
+
103
+ def write_data_to_files
104
+ write_to_file(patients_json.to_json, 'patients')
105
+ write_to_file(fhir_encounters.to_json, 'visits')
106
+ write_to_file(fhir_observations.to_json, 'observations')
107
+ write_to_file(fhir_medication_dispense.to_json, 'medications')
108
+ end
109
+
110
+ # ===================================
111
+ # REGION: REGIMEN COMBINATIONS
112
+ # ===================================
113
+
114
+ # rubocop:disable Metrics/MethodLength
115
+ def load_regimen_combinations
116
+ combinations = ActiveRecord::Base.connection.select_all <<~SQL
117
+ SELECT
118
+ moh_regimen_combination.regimen_combination_id,
119
+ GROUP_CONCAT(DISTINCT moh_regimen_combination_drug.drug_id
120
+ ORDER BY moh_regimen_combination_drug.drug_id
121
+ ASC SEPARATOR ',') AS drugs,
122
+ moh_regimen_name.name
123
+ FROM moh_regimen_combination_drug
124
+ INNER JOIN (
125
+ SELECT regimen_combination_id
126
+ FROM moh_regimen_combination_drug
127
+ ) AS potential_combinations ON potential_combinations.regimen_combination_id = moh_regimen_combination_drug.regimen_combination_id
128
+ INNER JOIN moh_regimen_combination ON moh_regimen_combination.regimen_combination_id = moh_regimen_combination_drug.regimen_combination_id
129
+ INNER JOIN moh_regimen_name ON moh_regimen_name.regimen_name_id = moh_regimen_combination.regimen_name_id
130
+ GROUP BY moh_regimen_combination.regimen_combination_id;
131
+ SQL
132
+
133
+ (combinations || []).each do |combination|
134
+ regimen_combinations[combination['drugs']] = combination['name']
135
+ end
136
+ end
137
+
138
+ # ===================================
139
+ # REGION: AWS FHIR PATIENT
140
+ # ===================================
141
+
142
+ def fetch_patients(schema:)
143
+ ActiveRecord::Base.connection.select_all <<~SQL
144
+ SELECT
145
+ p.person_id, given_name, middle_name, family_name, gender, birthdate,
146
+ phone.value phone_number, village.value village_name
147
+ FROM #{schema}.person p
148
+ INNER JOIN #{schema}.patient ON patient.patient_id = p.person_id AND p.voided = 0
149
+ INNER JOIN #{schema}.patient_program pp ON pp.patient_id = p.person_id AND pp.voided = 0 AND pp.program_id = 1
150
+ LEFT JOIN #{schema}.person_name n ON n.person_id = p.person_id AND n.voided = 0
151
+ LEFT JOIN #{schema}.person_attribute phone ON phone.person_id = p.person_id AND phone.voided = 0 AND phone.person_attribute_type_id = 12
152
+ LEFT JOIN #{schema}.person_attribute village ON village.person_id = p.person_id AND village.voided = 0 AND village.person_attribute_type_id = 19;
153
+ SQL
154
+ end
155
+
156
+ # rubocop:disable Metrics/AbcSize
157
+ def build_patient_json(patient, site_name)
158
+ patient_id = patient['person_id'].to_i
159
+ village_name = get_or_generate_faker_value(patient['village_name'], Faker::Address, :community)
160
+ cell_number = get_or_generate_faker_value(patient['phone_number'], Faker::PhoneNumber, :phone_number)
161
+ family_name = get_or_generate_faker_value(patient['family_name'], Faker::Name, :last_name)
162
+ given_name = get_or_generate_faker_value(patient['given_name'], Faker::Name, :first_name)
163
+ middle_name = get_or_generate_faker_value(patient['middle_name'], Faker::Name, :middle_name)
164
+
165
+ patients_json << FHIR::Patient.new(
166
+ id: patient_id,
167
+ identifier: [{ system: "#{site_name}_#{patient_id}", value: patient_id }],
168
+ name: [{ use: 'official', family: family_name, given: [given_name, middle_name] }],
169
+ gender: patient['gender'].match(/f/i) ? 'female' : 'male',
170
+ birthDate: patient['birthdate'],
171
+ address: [{ use: 'home', village: village_name }],
172
+ telecom: [{ system: 'phone', value: cell_number, use: 'mobile' }]
173
+ )
174
+ end
175
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
176
+
177
+ def fetch_visits(patients_ids:, schema:)
178
+ ActiveRecord::Base.connection.select_all <<~SQL
179
+ SELECT * FROM #{schema}.data_sanitizer_visits
180
+ WHERE patient_id IN(#{patients_ids.join(',')})
181
+ SQL
182
+ end
183
+
184
+ def process_visits(visits:)
185
+ (visits || []).map do |visit|
186
+ arvs_given = visit['drugs_dispensed'].to_s.split(',').map(&:to_i).sort
187
+ regimen = arvs_given.blank? ? 'N/A' : (regimen_combinations[arvs_given.join(',').squish] || 'Unknown')
188
+ aws_fhir_encounter(visit:, regimen:)
189
+ end
190
+ end
191
+
192
+ # ===================================
193
+ # REGION: AWS FHIR ENCOUNTER
194
+ # ===================================
195
+
196
+ def aws_fhir_encounter(visit:, regimen:)
197
+ patient_id = visit['patient_id'].to_i
198
+ fhir_encounters << create_fhir_encounter(patient_id, visit['visit_date'])
199
+
200
+ observations = process_indicators(visit, regimen)
201
+ create_fhir_observations(observations, patient_id, visit['visit_date'])
202
+ end
203
+
204
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
205
+ def create_fhir_encounter(patient_id, visit_date)
206
+ FHIR::Encounter.new(
207
+ id: Digest::SHA256.hexdigest(patient_id.to_s),
208
+ status: 'finished',
209
+ class: {
210
+ system: 'EGPAF Malawi EMR',
211
+ code: 'PVE',
212
+ display: 'Patient visit encounter'
213
+ },
214
+ subject: {
215
+ reference: "Patient/#{patient_id}"
216
+ },
217
+ period: {
218
+ start: visit_date,
219
+ end: visit_date
220
+ }
221
+ )
222
+ end
223
+
224
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/BlockLength
225
+ def process_indicators(visit, regimen)
226
+ visit.keys.map do |indicator|
227
+ next if indicator.match(/patient_id|visit_date|outcome_concept_id|outcome_end_date|other/i)
228
+
229
+ case indicator
230
+ when /weight|height/i
231
+ {
232
+ code: (indicator.match(/weight/i) ? 5089 : 5090),
233
+ display: indicator.titleize,
234
+ value: visit[indicator],
235
+ unit: (indicator.match(/weight/i) ? 'Kg' : 'cm')
236
+ }
237
+ when /adherence/i
238
+ {
239
+ code: get_concept_name(indicator.gsub('_', ' ')).concept_id,
240
+ display: indicator.titleize,
241
+ value: (visit[indicator].blank? ? visit[indicator] : visit[indicator].to_s.gsub('%', '').to_f),
242
+ unit: '%'
243
+ }
244
+ when /drugs_dispensed/i
245
+ {
246
+ code: 6882,
247
+ display: 'ARV regimen type',
248
+ value: regimen
249
+ }
250
+ else
251
+ {
252
+ code: get_concept_name(indicator.gsub('_', ' ')).concept_id,
253
+ display: indicator.titleize,
254
+ value: visit[indicator]
255
+ }
256
+ end
257
+ end.compact
258
+ end
259
+
260
+ # rubocop:disable Metrics/PerceivedComplexity
261
+ def create_fhir_observations(observations, patient_id, visit_date)
262
+ observations.each do |obs|
263
+ next if obs.blank? || obs[:code].blank? || obs[:value].blank?
264
+
265
+ fhir_observations << FHIR::Observation.new(
266
+ status: 'final',
267
+ code: {
268
+ coding: [
269
+ {
270
+ system: 'EGPAF Malawi EMR',
271
+ code: obs[:code],
272
+ display: obs[:display]
273
+ }
274
+ ]
275
+ },
276
+ subject: {
277
+ reference: "Patient/#{patient_id}"
278
+ },
279
+ encounter: {
280
+ reference: "Encounter/#{fhir_encounters.last.id}"
281
+ },
282
+ effectiveDateTime: visit_date
283
+ )
284
+
285
+ if obs[:unit]
286
+ fhir_observations.last.valueQuantity = {
287
+ value: obs[:value],
288
+ unit: obs[:unit],
289
+ system: 'EGPAF Malawi EMR',
290
+ code: obs[:code]
291
+ }
292
+ else
293
+ fhir_observations.last.valueCodeableConcept = {
294
+ coding: [
295
+ {
296
+ system: 'EGPAF Malawi EMR',
297
+ code: obs[:code],
298
+ display: (if obs[:value] == '1065'
299
+ 'Yes'
300
+ else
301
+ obs[:value] == '1066' ? 'No' : obs[:value]
302
+ end)
303
+ }
304
+ ]
305
+ }
306
+ end
307
+ end
308
+ end
309
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/MethodLength, Metrics/BlockLength, Metrics/PerceivedComplexity
310
+
311
+ # ===================================
312
+ # REGION: AWS FHIR MEDICATIONS
313
+ # ===================================
314
+
315
+ def process_medication(schema:)
316
+ orders = fetch_medication(schema:)
317
+ create_fhir_medications(orders:)
318
+ end
319
+
320
+ def fetch_medication(schema:)
321
+ ActiveRecord::Base.connection.select_all <<~SQL
322
+ SELECT * FROM #{schema}.data_sanitizer_arv_orders
323
+ SQL
324
+ end
325
+
326
+ def create_fhir_medications(orders:)
327
+ fhir_medication_dispense << (orders || []).map do |order|
328
+ patient_id = order['patient_id'].to_i
329
+ order_id = order['order_id'].to_i
330
+ encounter_id = order['encounter_id'].to_i
331
+ FHIR::MedicationDispense.new(
332
+ identifier: {
333
+ value: order_id
334
+ },
335
+ status: 'completed',
336
+ patient: {
337
+ reference: encounter_id
338
+ },
339
+ medication: {
340
+ code: {
341
+ text: order['drug_name'],
342
+ coding: [
343
+ {
344
+ system: 'EGPAF Malawi EMR',
345
+ code: order['drug_id'],
346
+ display: order['drug_name']
347
+ }
348
+ ]
349
+ }
350
+ },
351
+ dispensed: {
352
+ quantity: {
353
+ value: order['quantity'].to_f,
354
+ unit: order['units']
355
+ },
356
+ type: {
357
+ text: order['units']
358
+ },
359
+ dateTime: order['start_date']
360
+ },
361
+ dosageInstruction: [
362
+ {
363
+ text: order['instructions']
364
+ }
365
+ ],
366
+ dispense: {
367
+ validityPeriod: {
368
+ start: order['start_date'],
369
+ end: order['runout_date']
370
+ }
371
+ }
372
+ )
373
+ end
374
+ end
375
+
376
+ # ===================================
377
+ # Helper methods
378
+ # ===================================
379
+
380
+ def get_or_generate_faker_value(attribute, faker_module, faker_method)
381
+ return '' if attribute.blank?
382
+
383
+ @faker_names[attribute] ||= faker_module.send(faker_method)
384
+ end
385
+
386
+ def get_concept_name(name)
387
+ concept_names[name] ||= ConceptName.find_by_name(name)
388
+ end
389
+
390
+ def clear_directory
391
+ dir_path = Rails.root.join('log/openmrs_data_sanitizer')
392
+ return unless Dir.exist?(dir_path)
393
+
394
+ FileUtils.rm_rf(dir_path)
395
+ end
396
+
397
+ def write_to_file(json_file, file_name)
398
+ dir_path = Rails.root.join('log/openmrs_data_sanitizer')
399
+ file_path = dir_path.join("#{file_name}.json")
400
+
401
+ # Ensure the directory exists
402
+ Dir.mkdir(dir_path) unless Dir.exist?(dir_path)
403
+
404
+ # Use the mutex to handle concurrent writes
405
+ FILE_MUTEX.synchronize do
406
+ File.open(file_path, 'a') do |out_file|
407
+ out_file.puts(json_file)
408
+ puts "Created file: #{file_path}"
409
+ end
410
+ end
411
+ end
412
+ end
413
+ # rubocop:enable Metrics/ClassLength
414
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenmrsDataSanitizer
4
+ # Engine class
5
+ class Engine < ::Rails::Engine
6
+ isolate_namespace OpenmrsDataSanitizer
7
+ config.generators.api_only = true
8
+
9
+ config.generators do |g|
10
+ g.test_framework :rspec
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenmrsDataSanitizer
4
+ VERSION = '1.0.1'
5
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Path: lib/malawi_hiv_program_reports.rb
4
+ require 'openmrs_data_sanitizer/version'
5
+ require 'openmrs_data_sanitizer/engine'
6
+
7
+ # main module of the gem
8
+ module OpenmrsDataSanitizer
9
+ def self.hi
10
+ puts 'Welcome to MalawiHivProgramReports!'
11
+ end
12
+ end
metadata ADDED
@@ -0,0 +1,150 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: openmrs_data_sanitizer
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Mwatha
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-08-19 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: concurrent-ruby
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: faker
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.20'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.20'
41
+ - !ruby/object:Gem::Dependency
42
+ name: fhir_models
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: mysql2
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: 0.5.6
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 0.5.6
69
+ - !ruby/object:Gem::Dependency
70
+ name: rails
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: 7.1.3
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: 7.1.3.4
79
+ type: :runtime
80
+ prerelease: false
81
+ version_requirements: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - "~>"
84
+ - !ruby/object:Gem::Version
85
+ version: 7.1.3
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: 7.1.3.4
89
+ - !ruby/object:Gem::Dependency
90
+ name: rspec
91
+ requirement: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '3.13'
96
+ type: :runtime
97
+ prerelease: false
98
+ version_requirements: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '3.13'
103
+ description: This gem will mask all patient identifyable data before exporting to
104
+ AWS FHIR API
105
+ email:
106
+ - mwathabwanali@gmail.com
107
+ executables: []
108
+ extensions: []
109
+ extra_rdoc_files:
110
+ - README.md
111
+ files:
112
+ - CHANGELOG.md
113
+ - Gemfile
114
+ - LICENSE.txt
115
+ - README.md
116
+ - Rakefile
117
+ - app/services/openmrs_data_sanitizer/data_aggregator.rb
118
+ - app/services/openmrs_data_sanitizer/data_sanitizer.rb
119
+ - lib/openmrs_data_sanitizer.rb
120
+ - lib/openmrs_data_sanitizer/engine.rb
121
+ - lib/openmrs_data_sanitizer/version.rb
122
+ homepage: https://github.com/egpaf-global/openmrs_data_sanitizer
123
+ licenses:
124
+ - MIT
125
+ metadata:
126
+ homepage_uri: https://github.com/egpaf-global/openmrs_data_sanitizer
127
+ source_code_uri: https://github.com/egpaf-global/openmrs_data_sanitizer
128
+ changelog_uri: https://github.com/egpaf-global/openmrs_data_sanitizer/blob/master/CHANGELOG.md
129
+ rubygems_mfa_required: 'true'
130
+ post_install_message:
131
+ rdoc_options: []
132
+ require_paths:
133
+ - lib
134
+ required_ruby_version: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: 3.2.0
139
+ required_rubygems_version: !ruby/object:Gem::Requirement
140
+ requirements:
141
+ - - ">="
142
+ - !ruby/object:Gem::Version
143
+ version: '0'
144
+ requirements: []
145
+ rubygems_version: 3.4.5
146
+ signing_key:
147
+ specification_version: 4
148
+ summary: This gem will mask all patient identifyable data before exporting to AWS
149
+ FHIR API
150
+ test_files: []