Skip to content

Commit 59c2074

Browse files
CodeCorvinthomasleese
authored andcommitted
Merge branch 'next' into enable_dismiss_important_notices_for_archived_patients
2 parents d00262f + adbe6d5 commit 59c2074

23 files changed

Lines changed: 527 additions & 221 deletions

app/controllers/class_imports_controller.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ def create
2828
**class_import_params
2929
)
3030

31-
@class_import.load_data!
3231
if @class_import.invalid?
3332
render :new, status: :unprocessable_content and return
3433
end

app/controllers/cohort_imports_controller.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ def create
2626
**cohort_import_params
2727
)
2828

29-
@cohort_import.load_data!
3029
if @cohort_import.invalid?
3130
render :new, status: :unprocessable_content and return
3231
end

app/controllers/immunisation_imports_controller.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ def create
2222
**immunisation_import_params
2323
)
2424

25-
@immunisation_import.load_data!
2625
if @immunisation_import.invalid?
2726
render :new, status: :unprocessable_content and return
2827
end

app/jobs/process_import_job.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@ class ProcessImportJob < ApplicationJob
66
queue_as :imports
77

88
def perform(import)
9+
return if import.processed?
10+
911
SemanticLogger.tagged(import_id: import.id) do
1012
Sentry.set_tags(import_id: import.id)
1113

1214
import.parse_rows!
1315

16+
return if import.invalid?
1417
return if import.rows_are_invalid?
1518

1619
import.process!

app/models/concerns/csv_importable.rb

Lines changed: 42 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ module CSVImportable
66
MAX_CSV_ROWS = 20_000
77

88
included do
9-
attr_accessor :csv_is_malformed, :data, :rows
9+
attr_accessor :rows
1010

1111
encrypts :csv_data
1212

@@ -83,45 +83,56 @@ module CSVImportable
8383
before_save :ensure_processed_with_count_statistics
8484
end
8585

86-
def csv=(file)
87-
self.csv_data = remove_bom_if_present(file&.read)
88-
self.csv_filename = file&.original_filename
89-
end
86+
# Assign an uploaded CSV file to this import.
87+
#
88+
# Reads the uploaded file into {Import::CSVData}, stores the original filename,
89+
# and updates {#rows_count} based on the parsed CSV data.
90+
#
91+
# If the file contains a UTF byte-order mark (BOM) (common when exporting from
92+
# Excel), the encoding is detected and handled before reading.
93+
#
94+
# Raises an error if called on a persisted record, as changing the CSV file for
95+
# an existing import is not allowed.
96+
#
97+
# @param source [ActionDispatch::Http::UploadedFile] the uploaded CSV file
98+
# @raise [RuntimeError] if called on a persisted record
99+
# @raise [ArgumentError] if `source` is not an uploaded file
100+
def csv=(source)
101+
if persisted?
102+
raise "Cannot change the CSV file for an existing import. " \
103+
"Create a new import instead."
104+
end
90105

91-
# CSV files exported from Excel may have a BOM.
92-
# https://en.wikipedia.org/wiki/Byte_order_mark
93-
# e.g. if you create a new class import from scratch in Excel on Mac v16,
94-
# save the file as CSV, and upload it.
95-
def remove_bom_if_present(data)
96-
StringIO.new(data).tap(&:set_encoding_by_bom).read
106+
if source.is_a?(ActionDispatch::Http::UploadedFile)
107+
# CSV files exported from Excel may have a BOM.
108+
# https://en.wikipedia.org/wiki/Byte_order_mark
109+
# e.g. if you create a new class import from scratch in Excel on Mac v16,
110+
# save the file as CSV, and upload it.
111+
self.csv_data = source.to_io.tap(&:set_encoding_by_bom).read
112+
self.csv_filename = source&.original_filename
113+
self.rows_count = csv_data_object&.count
114+
else
115+
raise ArgumentError, "Expected an uploaded file, got #{source}"
116+
end
97117
end
98118

99119
# Needed so that validations match the form field name.
100120
def csv
101121
csv_data
102122
end
103123

104-
def csv_removed?
105-
csv_removed_at != nil
124+
def csv_data_object
125+
@csv_data_object ||= Import::CSVData.new(csv_data)
106126
end
107127

108-
def load_data!
109-
return if invalid?
110-
111-
self.data ||= CSVParser.call(csv_data)
112-
self.rows_count = data.count
113-
rescue CSV::MalformedCSVError
114-
self.csv_is_malformed = true
128+
def csv_removed?
129+
csv_removed_at != nil
115130
end
116131

117132
def parse_rows!
118-
load_data! if data.nil?
119133
return if invalid?
120134

121-
self.rows =
122-
remove_trailing_blank_rows(data)
123-
.then { |rows| has_instruction_row? ? rows.drop(1) : rows }
124-
.map { |row_data| parse_row(row_data) }
135+
self.rows = csv_data_object.records.map { |row_data| parse_row(row_data) }
125136

126137
if invalid?
127138
self.serialized_errors = errors.to_hash
@@ -130,46 +141,10 @@ def parse_rows!
130141
end
131142
end
132143

133-
def remove_trailing_blank_rows(table)
134-
found_values = false
135-
136-
# map(&:itself) because CSV::Table doesn't have a reverse method
137-
rows_in_reverse_order = table.map(&:itself).reverse
138-
139-
filtered_rows =
140-
rows_in_reverse_order.select do |row|
141-
if found_values
142-
true
143-
elsif row.fields.all?(&:blank?)
144-
false
145-
else
146-
found_values = true
147-
true
148-
end
149-
end
150-
151-
filtered_rows.reverse
152-
end
153-
154-
def has_instruction_row?
155-
data&.first&.[](0)&.to_s&.match?(/\A(Required|Optional)([,.:]|$)/)
156-
end
157-
158144
def processed?
159145
processed_at != nil
160146
end
161147

162-
def process!
163-
return if processed?
164-
165-
parse_rows! if rows.nil?
166-
return if invalid?
167-
168-
process_import!
169-
170-
TeamCachedCounts.new(team).reset_import_issues!
171-
end
172-
173148
def remove!
174149
return if csv_removed?
175150
update!(csv_data: nil, csv_removed_at: Time.zone.now)
@@ -186,24 +161,23 @@ def load_serialized_errors!(limit: nil)
186161
end
187162

188163
def csv_is_valid
189-
return unless csv_is_malformed
190-
191-
errors.add(:csv, :invalid)
164+
errors.add(:csv, :invalid) unless csv_data_object.well_formed?
192165
end
193166

194167
def csv_is_not_too_large
195-
return unless data
168+
return unless csv_data
196169

197170
if rows_count > MAX_CSV_ROWS
198171
errors.add(:csv, :too_many_rows, count: MAX_CSV_ROWS)
199172
end
200173
end
201174

202175
def csv_has_records
203-
return unless data
176+
return unless csv_data
204177

205178
csv_has_no_records =
206-
data.empty? || (data.count == 1 && has_instruction_row?)
179+
csv_data_object.empty? ||
180+
(csv_data_object.count == 1 && csv_data_object.has_instruction_row?)
207181
errors.add(:csv, :empty) if csv_has_no_records
208182
end
209183

@@ -214,7 +188,7 @@ def rows_are_valid
214188

215189
check_rows_are_unique
216190

217-
row_offset = has_instruction_row? ? 3 : 2
191+
row_offset = csv_data_object.has_instruction_row? ? 3 : 2
218192

219193
rows.each.with_index do |row, index|
220194
next if row.errors.empty?

app/models/immunisation_import.rb

Lines changed: 32 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,41 @@ def records_count
6464
end
6565
end
6666

67+
def process!
68+
raise "'rows' are empty. Call parse_rows! before processing." if rows.nil?
69+
70+
counts = count_columns.index_with(0)
71+
72+
@vaccination_records_batch = Set.new
73+
@patients_batch = Set.new
74+
@patient_locations_batch = Set.new
75+
@archive_reasons_batch = Set.new
76+
77+
ActiveRecord::Base.transaction do
78+
rows.each do |row|
79+
count_column_to_increment = process_row(row)
80+
counts[count_column_to_increment] += 1
81+
bulk_import(rows: 100)
82+
end
83+
84+
bulk_import(rows: :all)
85+
86+
postprocess_rows!
87+
88+
update_columns(processed_at: Time.zone.now, status: :processed, **counts)
89+
end
90+
91+
post_commit!
92+
UpdatePatientsFromPDS.call(patients, queue: :imports)
93+
94+
TeamCachedCounts.new(team).reset_import_issues!
95+
end
96+
6797
private
6898

99+
# TODO: This is called by the `rows_are_valid` validation. Move it to it's own validation.
69100
def check_rows_are_unique
70-
row_offset = has_instruction_row? ? 3 : 2
101+
row_offset = csv_data_object.has_instruction_row? ? 3 : 2
71102

72103
rows
73104
.map(&:full_row_deduplication_attributes)
@@ -124,32 +155,6 @@ def process_row(row)
124155
count_column_to_increment
125156
end
126157

127-
def process_import!
128-
counts = count_columns.index_with(0)
129-
130-
@vaccination_records_batch = Set.new
131-
@patients_batch = Set.new
132-
@patient_locations_batch = Set.new
133-
@archive_reasons_batch = Set.new
134-
135-
ActiveRecord::Base.transaction do
136-
rows.each do |row|
137-
count_column_to_increment = process_row(row)
138-
counts[count_column_to_increment] += 1
139-
bulk_import(rows: 100)
140-
end
141-
142-
bulk_import(rows: :all)
143-
144-
postprocess_rows!
145-
146-
update_columns(processed_at: Time.zone.now, status: :processed, **counts)
147-
end
148-
149-
post_commit!
150-
UpdatePatientsFromPDS.call(patients, queue: :imports)
151-
end
152-
153158
def bulk_import(rows: 100)
154159
return if rows != :all && @vaccination_records_batch.size < rows
155160

app/models/import/csv_data.rb

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# frozen_string_literal: true
2+
3+
class Import::CSVData
4+
attr_accessor :data, :malformed
5+
6+
# Use with serialization in the Import model
7+
def initialize(data)
8+
@data = data
9+
end
10+
11+
def well_formed?
12+
csv_table
13+
!malformed
14+
end
15+
16+
def empty? = csv_table.blank?
17+
18+
def csv_table
19+
@csv_table ||=
20+
begin
21+
CSVParser.call(data) if data.present?
22+
rescue CSV::MalformedCSVError
23+
@malformed = true
24+
nil
25+
end
26+
end
27+
28+
def count = csv_table&.count || 0
29+
30+
def records(&block)
31+
remove_trailing_blank_rows
32+
.then { |rows| has_instruction_row? ? rows.drop(1) : rows }
33+
.each(&block)
34+
end
35+
36+
def has_instruction_row?
37+
csv_table&.first&.[](0)&.to_s&.match?(/\A(Required|Optional)([,.:]|$)/)
38+
end
39+
40+
private
41+
42+
def remove_trailing_blank_rows
43+
found_values = false
44+
45+
# map(&:itself) because CSV::Table doesn't have a reverse method
46+
rows_in_reverse_order = csv_table.map(&:itself).reverse
47+
48+
filtered_rows =
49+
rows_in_reverse_order.select do |row|
50+
if found_values
51+
true
52+
elsif row.fields.all?(&:blank?)
53+
false
54+
else
55+
found_values = true
56+
true
57+
end
58+
end
59+
60+
filtered_rows.reverse
61+
end
62+
end

app/models/patient_import.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ def records_count
3434
changesets.from_file.count
3535
end
3636

37-
def process_import!
37+
def process!
38+
raise "'rows' are empty. Call parse_rows! before processing." if rows.nil?
39+
3840
changesets =
3941
rows.each_with_index.map do |row, row_number|
4042
PatientChangeset.from_import_row(row:, import: self, row_number:)
@@ -54,6 +56,8 @@ def process_import!
5456
return if changesets_are_invalid?
5557

5658
enqueue_review_jobs(self.changesets)
59+
60+
TeamCachedCounts.new(team).reset_import_issues!
5761
end
5862

5963
def validate_pds_match_rate!
@@ -179,6 +183,8 @@ def enqueue_pds_cascading_searches(changesets)
179183
end
180184
end
181185

186+
# TODO: This is called by the `rows_are_valid` validation. Move it to it's own validation.
187+
# TODO: Currently entested, unlike the equivalent in ImmunisationImport. Add tests.
182188
def check_rows_are_unique
183189
rows
184190
.map(&:nhs_number_value)

0 commit comments

Comments
 (0)