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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +99 -0
- data/Rakefile +39 -0
- data/app/assets/config/importo_manifest.js +2 -0
- data/app/assets/javascripts/importo/application.js +13 -0
- data/app/assets/stylesheets/importo/application.css +15 -0
- data/app/controllers/concerns/maintenance_standards.rb +24 -0
- data/app/controllers/importo/application_controller.rb +8 -0
- data/app/controllers/importo/imports_controller.rb +65 -0
- data/app/helpers/importo/application_helper.rb +17 -0
- data/app/importers/concerns/exportable.rb +128 -0
- data/app/importers/concerns/importable.rb +168 -0
- data/app/importers/concerns/importer_dsl.rb +122 -0
- data/app/importers/concerns/original.rb +150 -0
- data/app/importers/concerns/result_feedback.rb +69 -0
- data/app/importers/concerns/revertable.rb +41 -0
- data/app/importers/importo/base_importer.rb +32 -0
- data/app/mailers/importo/application_mailer.rb +8 -0
- data/app/models/importo/application_record.rb +7 -0
- data/app/models/importo/import.rb +93 -0
- data/app/services/importo/application_context.rb +6 -0
- data/app/services/importo/application_service.rb +9 -0
- data/app/services/importo/callback_service.rb +14 -0
- data/app/services/importo/import_context.rb +9 -0
- data/app/services/importo/import_service.rb +15 -0
- data/app/services/importo/revert_service.rb +14 -0
- data/app/tables/importo/imports_table.rb +39 -0
- data/app/views/importo/imports/index.html.slim +2 -0
- data/app/views/importo/imports/new.html.slim +24 -0
- data/config/locales/en.yml +33 -0
- data/config/locales/nl.yml +28 -0
- data/config/routes.rb +13 -0
- data/db/migrate/20180409151031_create_importo_import.rb +21 -0
- data/db/migrate/20180628175535_add_locale_importo_import.rb +7 -0
- data/db/migrate/20190827093548_add_selected_fields_to_import.rb +5 -0
- data/lib/generators/importo/USAGE +8 -0
- data/lib/generators/importo/importer_generator.rb +10 -0
- data/lib/generators/importo/install_generator.rb +27 -0
- data/lib/generators/templates/README +14 -0
- data/lib/generators/templates/application_importer.rb +4 -0
- data/lib/generators/templates/importer.rb +24 -0
- data/lib/generators/templates/importo.rb +21 -0
- data/lib/importo/acts_as_import_owner.rb +11 -0
- data/lib/importo/configuration.rb +68 -0
- data/lib/importo/engine.rb +23 -0
- data/lib/importo/import_column.rb +55 -0
- data/lib/importo/import_helpers.rb +10 -0
- data/lib/importo/version.rb +5 -0
- data/lib/importo.rb +29 -0
- 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,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
|