importo 2.0.4

Sign up to get free protection for your applications and to get access to all the features.
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