importo 2.0.4

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 (51) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +99 -0
  4. data/Rakefile +39 -0
  5. data/app/assets/config/importo_manifest.js +2 -0
  6. data/app/assets/javascripts/importo/application.js +13 -0
  7. data/app/assets/stylesheets/importo/application.css +15 -0
  8. data/app/controllers/concerns/maintenance_standards.rb +24 -0
  9. data/app/controllers/importo/application_controller.rb +8 -0
  10. data/app/controllers/importo/imports_controller.rb +65 -0
  11. data/app/helpers/importo/application_helper.rb +17 -0
  12. data/app/importers/concerns/exportable.rb +128 -0
  13. data/app/importers/concerns/importable.rb +168 -0
  14. data/app/importers/concerns/importer_dsl.rb +122 -0
  15. data/app/importers/concerns/original.rb +150 -0
  16. data/app/importers/concerns/result_feedback.rb +69 -0
  17. data/app/importers/concerns/revertable.rb +41 -0
  18. data/app/importers/importo/base_importer.rb +32 -0
  19. data/app/mailers/importo/application_mailer.rb +8 -0
  20. data/app/models/importo/application_record.rb +7 -0
  21. data/app/models/importo/import.rb +93 -0
  22. data/app/services/importo/application_context.rb +6 -0
  23. data/app/services/importo/application_service.rb +9 -0
  24. data/app/services/importo/callback_service.rb +14 -0
  25. data/app/services/importo/import_context.rb +9 -0
  26. data/app/services/importo/import_service.rb +15 -0
  27. data/app/services/importo/revert_service.rb +14 -0
  28. data/app/tables/importo/imports_table.rb +39 -0
  29. data/app/views/importo/imports/index.html.slim +2 -0
  30. data/app/views/importo/imports/new.html.slim +24 -0
  31. data/config/locales/en.yml +33 -0
  32. data/config/locales/nl.yml +28 -0
  33. data/config/routes.rb +13 -0
  34. data/db/migrate/20180409151031_create_importo_import.rb +21 -0
  35. data/db/migrate/20180628175535_add_locale_importo_import.rb +7 -0
  36. data/db/migrate/20190827093548_add_selected_fields_to_import.rb +5 -0
  37. data/lib/generators/importo/USAGE +8 -0
  38. data/lib/generators/importo/importer_generator.rb +10 -0
  39. data/lib/generators/importo/install_generator.rb +27 -0
  40. data/lib/generators/templates/README +14 -0
  41. data/lib/generators/templates/application_importer.rb +4 -0
  42. data/lib/generators/templates/importer.rb +24 -0
  43. data/lib/generators/templates/importo.rb +21 -0
  44. data/lib/importo/acts_as_import_owner.rb +11 -0
  45. data/lib/importo/configuration.rb +68 -0
  46. data/lib/importo/engine.rb +23 -0
  47. data/lib/importo/import_column.rb +55 -0
  48. data/lib/importo/import_helpers.rb +10 -0
  49. data/lib/importo/version.rb +5 -0
  50. data/lib/importo.rb +29 -0
  51. metadata +332 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a20759f65aaabdc3812973d96d19ba69854eeeb75ec421b779b6da6890b2d916
4
+ data.tar.gz: faaba7cb0ceef82f36b161049fdd0eda76c0b14363e7a78636756624cecd6b00
5
+ SHA512:
6
+ metadata.gz: 59e08eb163844c774f4cdf74bcbb1365b971049c7d986687e80d2acc99512c509907a1b148dd41f4d3e99dd1345c09b2a80595e9987fca06cf8479dff48e34bf
7
+ data.tar.gz: 0e750e8752d3467e4ffedd4305c1b81d17f981c604a02d48200464278a9914b87b946e6fc7a9a74b0f73900ad79508ecaf9c6e0ba44e9ac7fe8e63519e3a2f59
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2017 Andre Meij
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,99 @@
1
+ # Importo
2
+
3
+ Short description and motivation.
4
+
5
+ ## Usage
6
+
7
+ Add an `app/importers` folder to your Rails app which will contain all importers.
8
+ It's recommended to add an ApplicationImporter that inherits from `Importo::BaseImporter` and that all other importers inherit from.
9
+
10
+ ```ruby
11
+ class ApplicationImporter < Importo::BaseImporter
12
+ end
13
+ ```
14
+
15
+ ```ruby
16
+ class ProductsImporter < ApplicationImporter
17
+ includes_header true
18
+ allow_duplicates false
19
+ allow_export true
20
+
21
+ model Product
22
+ friendly_name 'Product'
23
+
24
+ introduction %i[what columns required_column first_line save_locally translated more_information]
25
+
26
+ column attribute: :id
27
+
28
+ # attributes
29
+ column attribute: :name
30
+ column attribute: :number
31
+ column attribute: :description, strip_tags: false
32
+ column attribute: :images do |value|
33
+ value.split(',').map do |image|
34
+ uri = URI.parse(image)
35
+
36
+ { filename: File.basename(uri.to_s), io: URI.open(uri) }
37
+ end
38
+ end
39
+
40
+ def export_scope
41
+ Current.account.products
42
+ end
43
+ end
44
+ ```
45
+
46
+ You should add translations to your locale files:
47
+
48
+ ```yaml
49
+ en:
50
+ importers:
51
+ products_importer:
52
+ introduction:
53
+ what: "With this Excel sheet multiple shipments can be imported at once. Mind the following:"
54
+ columns: "- Columns may be deleted or their order may be changed."
55
+ required_column: "- Columns in red are mandatory."
56
+ first_line: "- The first line is an example and must be removed."
57
+ save_locally: "- You can save this Excel file locally and fill it in partially, so you can re-use it."
58
+ translated: "- Columns and contents of this sheet are translated based on your locale, make sure you import in the same locale as you download the sample file."
59
+ more_information: 'Check the comments with each column and the "Explanation" sheet for more information.'
60
+ column:
61
+ name: Name
62
+ number: Number
63
+ description: Description
64
+ images: Images
65
+ explanation:
66
+ id: Record-id, only needed if you want to update an existing record
67
+ hint:
68
+ id: 36 characters, existing of hexadecimal numbers, separated by dashes
69
+ images: Allows multiple image urls, separated by comma
70
+ introduction: null
71
+ ```
72
+
73
+ ## Installation
74
+
75
+ Add this line to your application's Gemfile:
76
+
77
+ ```ruby
78
+ gem 'importo'
79
+ ```
80
+
81
+ And then execute:
82
+
83
+ ```bash
84
+ $ bundle
85
+ ```
86
+
87
+ Or install it yourself as:
88
+
89
+ ```bash
90
+ $ gem install importo
91
+ ```
92
+
93
+ ## Contributing
94
+
95
+ Contribution directions go here.
96
+
97
+ ## License
98
+
99
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require 'bundler/setup'
5
+ rescue LoadError
6
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
7
+ end
8
+
9
+ require 'rdoc/task'
10
+
11
+ RDoc::Task.new(:rdoc) do |rdoc|
12
+ rdoc.rdoc_dir = 'rdoc'
13
+ rdoc.title = 'Importo'
14
+ rdoc.options << '--line-numbers'
15
+ rdoc.rdoc_files.include('README.md')
16
+ rdoc.rdoc_files.include('lib/**/*.rb')
17
+ end
18
+
19
+ APP_RAKEFILE = File.expand_path('test/dummy/Rakefile', __dir__)
20
+ load 'rails/tasks/engine.rake'
21
+
22
+ load 'rails/tasks/statistics.rake'
23
+
24
+ require 'bundler/gem_tasks'
25
+
26
+ require 'rake/testtask'
27
+
28
+ Rake::TestTask.new(:test) do |t|
29
+ t.libs << 'test'
30
+ t.pattern = 'test/**/*_test.rb'
31
+ t.verbose = false
32
+ end
33
+
34
+ task default: :test
35
+
36
+ # Adds the Auxilium semver task
37
+ spec = Gem::Specification.find_by_name 'auxilium'
38
+ load "#{spec.gem_dir}/lib/tasks/semver.rake"
39
+
@@ -0,0 +1,2 @@
1
+ //= link_directory ../javascripts/importo .js
2
+ //= link_directory ../stylesheets/importo .css
@@ -0,0 +1,13 @@
1
+ // This is a manifest file that'll be compiled into application.js, which will include all the files
2
+ // listed below.
3
+ //
4
+ // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts,
5
+ // or any plugin's vendor/assets/javascripts directory can be referenced here using a relative path.
6
+ //
7
+ // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the
8
+ // compiled file. JavaScript code in this file should be added after the last require_* statement.
9
+ //
10
+ // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details
11
+ // about supported directives.
12
+ //
13
+ //= require_tree .
@@ -0,0 +1,15 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
+ * files in this directory. Styles in this file should be added after the last require_* statement.
11
+ * It is generally better to create a new file per style scope.
12
+ *
13
+ *= require_tree .
14
+ *= require_self
15
+ */
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaintenanceStandards
4
+ # Informs the user and redirects when needed
5
+ #
6
+ # @param result [Boolean] was update or create succesful
7
+ # @param path [URL] where to redirect to
8
+ # @param notice [String] What to show on success
9
+ # @param error [String] What to show on error
10
+ # @param render_action [Symbol] What to render
11
+ #
12
+ def flash_and_redirect(result, path, notice, error, render_action = :edit)
13
+ if result
14
+ if params[:commit] == 'continue'
15
+ flash.now[:notice] = notice
16
+ else
17
+ redirect_to(path, notice: notice) && return
18
+ end
19
+ else
20
+ flash.now[:error] = error
21
+ end
22
+ render render_action
23
+ end
24
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Importo
4
+ class ApplicationController < Importo.config.base_controller.constantize
5
+ include MaintenanceStandards
6
+ include Importo.config.admin_authentication_module.constantize if Importo.config.admin_authentication_module
7
+ end
8
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_dependency 'importo/application_controller'
4
+
5
+ module Importo
6
+ class ImportsController < ApplicationController
7
+ def new
8
+ @import = Import.new(kind: params[:kind], locale: I18n.locale)
9
+ end
10
+
11
+ def create
12
+ unless import_params
13
+ @import = Import.new(kind: params[:kind], locale: I18n.locale)
14
+ flash[:error] = t('.flash.no_file')
15
+ render :new
16
+ return
17
+ end
18
+ @import = Import.new(import_params.merge(locale: I18n.locale, importo_ownable: Importo.config.current_import_owner))
19
+ if @import.valid? && @import.schedule!
20
+ flash[:notice] = t('.flash.success', id: @import.id)
21
+ redirect_to action: :index
22
+ else
23
+ flash[:error] = t('.flash.error')
24
+ render :new
25
+ end
26
+ end
27
+
28
+ def undo
29
+ @import = Import.where(importo_ownable: Importo.config.current_import_owner).find(params[:id])
30
+ if @import.can_revert? && @import.revert
31
+ redirect_to action: :index, notice: 'Import reverted'
32
+ else
33
+ redirect_to action: :index, alert: 'Import could not be reverted'
34
+ end
35
+ end
36
+
37
+ def destroy
38
+ @import = Import.where(importo_ownable: Importo.config.current_import_owner).find(params[:id])
39
+ redirect_to(action: :index, alert: 'Not allowed') && return unless Importo.config.admin_can_destroy(@import)
40
+
41
+ @import.destroy
42
+ redirect_to action: :index
43
+ end
44
+
45
+ def sample
46
+ import = Import.new(kind: params[:kind], locale: I18n.locale)
47
+ send_data import.importer.sample_file.read, type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', filename: import.importer.file_name('sample')
48
+ end
49
+
50
+ def export
51
+ import = Import.new(kind: params[:kind], locale: I18n.locale)
52
+ send_data import.importer.export_file.read, type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', filename: import.importer.file_name('export')
53
+ end
54
+
55
+ def index
56
+ @imports = Importo.config.admin_visible_imports.order(created_at: :desc).limit(50)
57
+ end
58
+
59
+ private
60
+
61
+ def import_params
62
+ params.require(:import).permit(:original, :kind, :column_overrides, column_overrides: params.dig(:import, :column_overrides)&.keys)
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Importo
4
+ module ApplicationHelper
5
+ def respond_to_missing?(method)
6
+ method.ends_with?('_url') || method.ends_with?('_path')
7
+ end
8
+
9
+ def method_missing(method, *args, &block)
10
+ if main_app.respond_to?(method)
11
+ main_app.send(method, *args)
12
+ else
13
+ super
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,128 @@
1
+
2
+ # frozen_string_literal: true
3
+
4
+ require 'active_support/concern'
5
+
6
+ module Exportable
7
+ extend ActiveSupport::Concern
8
+
9
+ #
10
+ # Generates a sample excel file as a stream
11
+ #
12
+ def sample_file
13
+ export(sample_data)
14
+ end
15
+
16
+ def sample_data
17
+ [export_columns.map { |_, c| c.options[:example] || '' }]
18
+ end
19
+
20
+ #
21
+ # Generates an export based on the attributes and scope.
22
+ #
23
+ def export_file
24
+ export(export_data)
25
+ end
26
+
27
+ def export_data
28
+ export_scope.map { |record| export_row(record) }
29
+ end
30
+
31
+ def export_scope
32
+ if self.class.allow_export?
33
+ model.all
34
+ else
35
+ model.none
36
+ end
37
+ end
38
+
39
+ def export_row(record)
40
+ export_columns.map do |_, c|
41
+ value = ''
42
+
43
+ if c.options[:attribute]
44
+ if record.respond_to?(c.options[:attribute])
45
+ value = record.send(c.options[:attribute])
46
+ value = value&.body&.to_html if value.is_a?(ActionText::RichText)
47
+ end
48
+
49
+ value ||= record.attributes[c.options[:attribute].to_s]
50
+ end
51
+
52
+ value
53
+ end
54
+ end
55
+
56
+ def export(data_rows)
57
+ xls = Axlsx::Package.new
58
+ xls.use_shared_strings = true
59
+ workbook = xls.workbook
60
+ sheet = workbook.add_worksheet(name: friendly_name&.pluralize || model.name.demodulize.pluralize)
61
+ workbook.styles do |style|
62
+ introduction_style = style.add_style(bg_color: 'E2EEDA')
63
+ header_style = style.add_style(b: true, bg_color: 'A8D08E', border: { style: :thin, color: '000000' })
64
+ header_required_style = style.add_style(b: true, bg_color: 'A8D08E', fg_color: 'C00100', border: { style: :thin, color: '000000' })
65
+
66
+ # Introduction
67
+ introduction.each_with_index do |intro, i|
68
+ text = intro.is_a?(Symbol) ? I18n.t(intro, scope: [:importers, self.class.name.underscore.to_s, :introduction]) : intro
69
+ sheet.add_row [text], style: [introduction_style] * export_columns.count
70
+ sheet.merge_cells "A#{i + 1}:#{nr_to_col(export_columns.count - 1)}#{i + 1}"
71
+ end
72
+
73
+ # Header row
74
+ sheet.add_row export_columns.values.map(&:name), style: export_columns.map { |_, c| c.options[:required] ? header_required_style : header_style }
75
+
76
+ export_columns.each.with_index do |f, i|
77
+ field = f.last
78
+ sheet.add_comment ref: "#{nr_to_col(i)}#{introduction.count + 1}", author: '', text: field.hint, visible: false if field.hint.present?
79
+ end
80
+
81
+ number = workbook.styles.add_style format_code: '#'
82
+ text = workbook.styles.add_style format_code: '@'
83
+
84
+ styles = export_columns.map { |_, c| c.options[:example].is_a?(Numeric) ? number : text }
85
+
86
+ # Examples
87
+ data_rows.each do |data|
88
+ sheet.add_row data, style: styles
89
+ end
90
+ end
91
+
92
+ sheet.column_info[0].width = 10
93
+
94
+ sheet = workbook.add_worksheet(name: I18n.t('importo.sheet.explanation.name'))
95
+
96
+ workbook.styles do |style|
97
+ introduction_style = style.add_style(bg_color: 'E2EEDA')
98
+ header_style = style.add_style(b: true, bg_color: 'A8D08E', border: { style: :thin, color: '000000' })
99
+
100
+ column_style = style.add_style(b: true)
101
+ required_style = style.add_style(b: true, fg_color: 'C00100')
102
+ wrap_style = workbook.styles.add_style alignment: { wrap_text: true }
103
+
104
+ # Introduction
105
+ introduction.each_with_index do |intro, i|
106
+ text = intro.is_a?(Symbol) ? I18n.t(intro, scope: [:importers, self.class.name.underscore.to_s, :introduction]) : intro
107
+ sheet.add_row [text], style: [introduction_style] * 2
108
+ sheet.merge_cells "A#{i + 1}:B#{i + 1}"
109
+ end
110
+
111
+ # Header row
112
+ sheet.add_row [I18n.t('importo.sheet.explanation.column'), I18n.t('importo.sheet.explanation.explanation')], style: [header_style] * 2
113
+ export_columns.each do |_, c|
114
+ styles = [c.options[:required] ? required_style : column_style, wrap_style]
115
+ sheet.add_row [c.name, c.explanation], style: styles
116
+ end
117
+ end
118
+
119
+ sheet.column_info[0].width = 40
120
+ sheet.column_info[1].width = 150
121
+
122
+ xls.to_stream
123
+ end
124
+
125
+ def export_columns
126
+ @export_columns ||= columns.reject { |_, column| column.options[:hidden] }
127
+ end
128
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ module Importable
6
+ extend ActiveSupport::Concern
7
+
8
+ #
9
+ # Build a record based on the row, when you override build, depending on your needs you will need to
10
+ # call populate yourself, or skip this altogether.
11
+ #
12
+ def build(row)
13
+ populate(row)
14
+ end
15
+
16
+ def convert_values(row)
17
+ return row if row.instance_variable_get('@importo_converted_values')
18
+
19
+ row.instance_variable_set('@importo_converted_values', true)
20
+
21
+ cols_to_populate = columns.select do |_, v|
22
+ v.options[:attribute].present?
23
+ end
24
+
25
+ cols_to_populate.each do |k, col|
26
+ attr = col.options[:attribute]
27
+
28
+ row[k] = import.column_overrides[col.attribute] if import.column_overrides[col.attribute]
29
+
30
+ if col.collection
31
+ # see if the value is part of the collection of (name, id) pairs, error if not.
32
+ value = col.collection.find { |item| item.last == row[k] || item.first == row[k] }&.last
33
+ raise StandardError, "#{row[k]} is not a valid value for #{col.name}" if value.nil? && row[k].present?
34
+ else
35
+ value ||= row[k]
36
+ end
37
+
38
+ if value.present? && col.proc
39
+ proc = col.proc
40
+ proc_result = instance_exec value, row, &proc
41
+ value = proc_result if proc_result
42
+ end
43
+ value ||= col.options[:default]
44
+
45
+ row[k] = value
46
+ end
47
+ row
48
+ end
49
+
50
+ #
51
+ # Assists in pre-populating the record for you
52
+ # It wil try and find the record by id, or initialize a new record with it's attributes set based on the mapping from columns
53
+ #
54
+ def populate(row, record = nil)
55
+ raise 'No attributes set for columns' unless columns.any? { |_, v| v.options[:attribute].present? }
56
+
57
+ row = convert_values(row)
58
+
59
+ result = if record
60
+ record
61
+ else
62
+ raise 'No model set' unless model
63
+
64
+ model.find_or_initialize_by(id: row['id'])
65
+ end
66
+
67
+ attributes = {}
68
+ cols_to_populate = columns.select do |_, v|
69
+ v.options[:attribute].present?
70
+ end
71
+
72
+ cols_to_populate.each do |k, col|
73
+ attr = col.options[:attribute]
74
+ attributes = set_attribute(attributes, attr, row[k]) if row[k].present?
75
+ end
76
+
77
+ result.assign_attributes(attributes)
78
+
79
+ result
80
+ end
81
+
82
+ #
83
+ # Mangle the record before saving
84
+ #
85
+ def before_save(_record, _row)
86
+ # Implement optionally in child class to mangle
87
+ end
88
+
89
+ #
90
+ # Any updates that have to be done after saving
91
+ #
92
+ def after_save(_record, _row)
93
+ # Implement optionally in child class to perform other updates
94
+ end
95
+
96
+ #
97
+ # Does the actual import
98
+ #
99
+ def import!
100
+ raise ArgumentError, 'Invalid data structure' unless structure_valid?
101
+
102
+ results = loop_data_rows do |attributes, index|
103
+ process_data_row(attributes, index)
104
+ end
105
+ @import.result.attach(io: results_file, filename: @import.importer.file_name('results'),
106
+ content_type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
107
+
108
+ @import.result_message = I18n.t('importo.importers.result_message', nr: results.compact.count, of: results.count,
109
+ start_row: data_start_row)
110
+ @import.complete!
111
+
112
+ true
113
+ rescue StandardError => e
114
+ @import.result_message = "Exception: #{e.message}"
115
+ Rails.logger.error "Importo exception: #{e.message} backtrace #{e.backtrace.join(';')}"
116
+ @import.failure!
117
+
118
+ false
119
+ end
120
+
121
+ private
122
+
123
+ ##
124
+ # Overridable failure method
125
+ #
126
+ def failure(_row, _record, index, exception)
127
+ Rails.logger.error "#{exception.message} processing row #{index}: #{exception.backtrace.join(';')}"
128
+ end
129
+
130
+ def process_data_row(attributes, index)
131
+ record = nil
132
+ row_hash = Digest::SHA256.base64digest(attributes.inspect)
133
+ duplicate_import = nil
134
+
135
+ begin
136
+ ActiveRecord::Base.transaction(requires_new: true) do
137
+ register_result(index, hash: row_hash, state: :processing)
138
+
139
+ record = build(attributes)
140
+ record.validate!
141
+ before_save(record, attributes)
142
+ record.save!
143
+ after_save(record, attributes)
144
+ duplicate_import = duplicate?(row_hash, record.id)
145
+ raise Importo::DuplicateRowError if duplicate_import
146
+
147
+ register_result(index, class: record.class.name, id: record.id, state: :success)
148
+ end
149
+ record
150
+ rescue Importo::DuplicateRowError
151
+ record_id = duplicate_import.results.find { |data| data['hash'] == row_hash }['id']
152
+ register_result(index, id: record_id, state: :duplicate,
153
+ message: "Row already imported successfully on #{duplicate_import.created_at.to_date}")
154
+ nil
155
+ rescue StandardError => e
156
+ errors = record.respond_to?(:errors) && record.errors.full_messages.join(', ')
157
+ error_message = "#{e.message} (#{e.backtrace.first.split('/').last})"
158
+ failure(attributes, record, index, e)
159
+ register_result(index, class: record.class.name, state: :failure, message: error_message, errors: errors)
160
+ nil
161
+ end
162
+ end
163
+
164
+ def set_attribute(hash, path, value)
165
+ tmp_hash = path.to_s.split('.').reverse.inject(value) { |h, s| { s => h } }
166
+ hash.deep_merge(tmp_hash)
167
+ end
168
+ end