@@ -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?
0 commit comments