rails_admin_import_no_encoding 0.1.0
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/CHANGELOG.md +48 -0
- data/MIT-LICENSE +20 -0
- data/README.md +476 -0
- data/Rakefile +17 -0
- data/app/views/rails_admin/main/_results.html.haml +18 -0
- data/app/views/rails_admin/main/_section.html.haml +10 -0
- data/app/views/rails_admin/main/import.html.haml +69 -0
- data/config/locales/README.md +3 -0
- data/config/locales/import.en.yml +50 -0
- data/lib/rails_admin_import/action.rb +46 -0
- data/lib/rails_admin_import/config/legacy_model.rb +42 -0
- data/lib/rails_admin_import/config/sections/import.rb +34 -0
- data/lib/rails_admin_import/config.rb +46 -0
- data/lib/rails_admin_import/eager_load.rb +3 -0
- data/lib/rails_admin_import/engine.rb +4 -0
- data/lib/rails_admin_import/formats/csv_importer.rb +81 -0
- data/lib/rails_admin_import/formats/dummy_importer.rb +16 -0
- data/lib/rails_admin_import/formats/file_importer.rb +47 -0
- data/lib/rails_admin_import/formats/json_importer.rb +32 -0
- data/lib/rails_admin_import/formats/xlsx_importer.rb +50 -0
- data/lib/rails_admin_import/formats.rb +34 -0
- data/lib/rails_admin_import/import_logger.rb +17 -0
- data/lib/rails_admin_import/import_model.rb +112 -0
- data/lib/rails_admin_import/importer.rb +248 -0
- data/lib/rails_admin_import/rails_admin_plugin.rb +23 -0
- data/lib/rails_admin_import/version.rb +3 -0
- data/lib/rails_admin_import.rb +23 -0
- metadata +115 -0
@@ -0,0 +1,47 @@
|
|
1
|
+
module RailsAdminImport
|
2
|
+
module Formats
|
3
|
+
class FileImporter
|
4
|
+
def initialize(import_model, params)
|
5
|
+
if params.has_key?(:file)
|
6
|
+
@filename = params[:file].tempfile
|
7
|
+
end
|
8
|
+
@import_model = import_model
|
9
|
+
end
|
10
|
+
|
11
|
+
attr_reader :filename, :error, :import_model
|
12
|
+
|
13
|
+
def valid?
|
14
|
+
if filename.nil?
|
15
|
+
@error = I18n.t("admin.import.missing_file")
|
16
|
+
false
|
17
|
+
else
|
18
|
+
true
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def each(&block)
|
23
|
+
return enum_for(:each) unless block_given?
|
24
|
+
|
25
|
+
if RailsAdminImport.config.logging && filename
|
26
|
+
copy_uploaded_file_to_log_dir
|
27
|
+
end
|
28
|
+
|
29
|
+
each_record(&block)
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def each_record
|
35
|
+
raise "Implement each_record in subclasses"
|
36
|
+
end
|
37
|
+
|
38
|
+
def copy_uploaded_file_to_log_dir
|
39
|
+
copy_filename = "#{Time.now.strftime("%Y-%m-%d-%H-%M-%S")}-import.csv"
|
40
|
+
dir_path = File.join(Rails.root, "log", "import")
|
41
|
+
FileUtils.mkdir_p(dir_path)
|
42
|
+
copy_path = File.join(dir_path, copy_filename)
|
43
|
+
FileUtils.copy(filename, copy_path)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
module RailsAdminImport
|
2
|
+
module Formats
|
3
|
+
class JSONImporter < FileImporter
|
4
|
+
Formats.register(:json, self)
|
5
|
+
Formats.register(:JSON, self)
|
6
|
+
|
7
|
+
# A method that yields a hash of attributes for each record to import
|
8
|
+
def each_record
|
9
|
+
File.open(filename) do |file|
|
10
|
+
data = JSON.load(file)
|
11
|
+
|
12
|
+
if data.is_a? Hash
|
13
|
+
# Load array from root key
|
14
|
+
data = data[root_key]
|
15
|
+
end
|
16
|
+
|
17
|
+
if !data.is_a? Array
|
18
|
+
raise ArgumentError, I18n.t("admin.import.invalid_json", root_key: root_key)
|
19
|
+
end
|
20
|
+
|
21
|
+
data.each do |record|
|
22
|
+
yield record.symbolize_keys
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def root_key
|
28
|
+
import_model.model.model_name.element.pluralize
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
require "csv"
|
2
|
+
|
3
|
+
module RailsAdminImport
|
4
|
+
module Formats
|
5
|
+
class XLSXImporter < FileImporter
|
6
|
+
Formats.register(:xlsx, self)
|
7
|
+
Formats.register(:XLSX, self)
|
8
|
+
|
9
|
+
autoload :SimpleXlsxReader, "simple_xlsx_reader"
|
10
|
+
|
11
|
+
def initialize(import_model, params)
|
12
|
+
super
|
13
|
+
@header_converter = RailsAdminImport.config.header_converter
|
14
|
+
end
|
15
|
+
|
16
|
+
# A method that yields a hash of attributes for each record to import
|
17
|
+
def each_record
|
18
|
+
doc = SimpleXlsxReader.open(filename)
|
19
|
+
sheet = doc.sheets.first
|
20
|
+
@headers = convert_headers(sheet.headers)
|
21
|
+
sheet.data.each do |row|
|
22
|
+
attr = convert_to_attributes(row)
|
23
|
+
yield attr unless attr.all? { |field, value| value.blank? }
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def convert_headers(headers)
|
30
|
+
headers.map do |h|
|
31
|
+
@header_converter.call(h || "")
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def convert_to_attributes(row)
|
36
|
+
row_with_headers = @headers.zip(row)
|
37
|
+
row_with_headers.each_with_object({}) do |(field, value), record|
|
38
|
+
next if field.nil?
|
39
|
+
field = field.to_sym
|
40
|
+
if import_model.has_multiple_values?(field)
|
41
|
+
field = import_model.pluralize_field(field)
|
42
|
+
(record[field] ||= []) << value
|
43
|
+
else
|
44
|
+
record[field] = value
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module RailsAdminImport
|
2
|
+
module Formats
|
3
|
+
class << self
|
4
|
+
def register(format, klass)
|
5
|
+
@registry[format.to_s] = klass
|
6
|
+
end
|
7
|
+
|
8
|
+
def from_file(file)
|
9
|
+
return unless file
|
10
|
+
File.extname(file.original_filename).sub(/^\./, '')
|
11
|
+
end
|
12
|
+
|
13
|
+
def for(format, *args)
|
14
|
+
@registry.fetch(format.to_s, DummyImporter).new(*args)
|
15
|
+
end
|
16
|
+
|
17
|
+
def all
|
18
|
+
@registry.keys
|
19
|
+
end
|
20
|
+
|
21
|
+
def reset
|
22
|
+
@registry = {}
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
reset
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
require "rails_admin_import/formats/dummy_importer"
|
31
|
+
require "rails_admin_import/formats/file_importer"
|
32
|
+
require "rails_admin_import/formats/csv_importer"
|
33
|
+
require "rails_admin_import/formats/json_importer"
|
34
|
+
require "rails_admin_import/formats/xlsx_importer"
|
@@ -0,0 +1,17 @@
|
|
1
|
+
module RailsAdminImport
|
2
|
+
class ImportLogger
|
3
|
+
attr_reader :logger
|
4
|
+
|
5
|
+
def initialize(log_file_name = "rails_admin_import.log")
|
6
|
+
if RailsAdminImport.config.logging
|
7
|
+
@logger = Logger.new(File.join(Rails.root, "log", log_file_name))
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
def info(message)
|
12
|
+
if RailsAdminImport.config.logging
|
13
|
+
@logger.info message
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,112 @@
|
|
1
|
+
module RailsAdminImport
|
2
|
+
class AssociationNotFound < StandardError
|
3
|
+
end
|
4
|
+
|
5
|
+
class ImportModel
|
6
|
+
def initialize(abstract_model)
|
7
|
+
@abstract_model = abstract_model
|
8
|
+
@config = abstract_model.config.import
|
9
|
+
@model = abstract_model.model
|
10
|
+
end
|
11
|
+
|
12
|
+
attr_reader :abstract_model, :model, :config
|
13
|
+
|
14
|
+
def display_name
|
15
|
+
abstract_model.config.label
|
16
|
+
end
|
17
|
+
|
18
|
+
def label_for_model(object)
|
19
|
+
object.public_send(label_method)
|
20
|
+
end
|
21
|
+
|
22
|
+
def label_method
|
23
|
+
@label_method ||= abstract_model.config.object_label_method
|
24
|
+
end
|
25
|
+
|
26
|
+
def importable_fields(model_config = config)
|
27
|
+
@importable_fields ||= {}
|
28
|
+
@importable_fields[model_config] ||= model_config.visible_fields.reject do |f|
|
29
|
+
# Exclude id, created_at and updated_at
|
30
|
+
model_config.default_excluded_fields.include? f.name
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def model_fields(model_config = config)
|
35
|
+
@model_fields ||= {}
|
36
|
+
@model_fields[model_config] ||= importable_fields(model_config).select { |f|
|
37
|
+
!f.association? || f.association.polymorphic?
|
38
|
+
}
|
39
|
+
end
|
40
|
+
|
41
|
+
def association_fields
|
42
|
+
@association_fields ||= importable_fields.select { |f|
|
43
|
+
f.association? && !f.association.polymorphic?
|
44
|
+
}
|
45
|
+
end
|
46
|
+
|
47
|
+
def single_association_fields
|
48
|
+
@single_association_fields ||= association_fields.select { |f|
|
49
|
+
!f.multiple?
|
50
|
+
}
|
51
|
+
end
|
52
|
+
|
53
|
+
def belongs_to_fields
|
54
|
+
@belongs_to_fields ||= single_association_fields.select { |f|
|
55
|
+
f.type == :belongs_to_association
|
56
|
+
}
|
57
|
+
end
|
58
|
+
|
59
|
+
def many_association_fields
|
60
|
+
@many_association_fields ||= association_fields.select { |f|
|
61
|
+
f.multiple?
|
62
|
+
}
|
63
|
+
end
|
64
|
+
|
65
|
+
def update_lookup_field_names
|
66
|
+
if @config.mapping_key_list.present?
|
67
|
+
@update_lookup_field_names = @config.mapping_key_list
|
68
|
+
else
|
69
|
+
@update_lookup_field_names ||= model_fields.map(&:name) + belongs_to_fields.map(&:foreign_key)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def associated_object(field, mapping_field, value)
|
74
|
+
klass = association_class(field)
|
75
|
+
klass.where(mapping_field => value).first or
|
76
|
+
raise AssociationNotFound, "#{klass}.#{mapping_field} = #{value}"
|
77
|
+
end
|
78
|
+
|
79
|
+
def association_class(field)
|
80
|
+
field.association.klass
|
81
|
+
end
|
82
|
+
|
83
|
+
def associated_config(field)
|
84
|
+
field.associated_model_config.import
|
85
|
+
end
|
86
|
+
|
87
|
+
def associated_model_fields(field)
|
88
|
+
@associated_fields ||= {}
|
89
|
+
if associated_config(field).mapping_key_list.present?
|
90
|
+
@associated_fields[field] ||= associated_config(field).mapping_key_list
|
91
|
+
else
|
92
|
+
@associated_fields[field] ||= associated_config(field).visible_fields.select { |f|
|
93
|
+
!f.association?
|
94
|
+
}.map(&:name)
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
def has_multiple_values?(field_name)
|
99
|
+
plural_name = pluralize_field(field_name)
|
100
|
+
many_association_fields.any? { |field| field.name == field_name || field.name == plural_name }
|
101
|
+
end
|
102
|
+
|
103
|
+
def pluralize_field(field_name)
|
104
|
+
@plural_fields ||= many_association_fields.map(&:name).each_with_object({}) { |name, h|
|
105
|
+
h[name.to_s.singularize.to_sym] = name
|
106
|
+
}
|
107
|
+
@plural_fields[field_name] || field_name
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
|
@@ -0,0 +1,248 @@
|
|
1
|
+
require "rails_admin_import/import_logger"
|
2
|
+
|
3
|
+
module RailsAdminImport
|
4
|
+
class Importer
|
5
|
+
def initialize(import_model, params)
|
6
|
+
@import_model = import_model
|
7
|
+
@params = params
|
8
|
+
end
|
9
|
+
|
10
|
+
attr_reader :import_model, :params
|
11
|
+
|
12
|
+
class UpdateLookupError < StandardError
|
13
|
+
end
|
14
|
+
|
15
|
+
def import(records)
|
16
|
+
begin
|
17
|
+
init_results
|
18
|
+
|
19
|
+
if records.count > RailsAdminImport.config.line_item_limit
|
20
|
+
return results = {
|
21
|
+
success: [],
|
22
|
+
error: [I18n.t('admin.import.import_error.line_item_limit', limit: RailsAdminImport.config.line_item_limit)]
|
23
|
+
}
|
24
|
+
end
|
25
|
+
|
26
|
+
perform_global_callback(:before_import)
|
27
|
+
|
28
|
+
with_transaction do
|
29
|
+
records.each do |record|
|
30
|
+
catch :skip do
|
31
|
+
import_record(record)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
rollback_if_error
|
36
|
+
end
|
37
|
+
|
38
|
+
perform_global_callback(:after_import)
|
39
|
+
rescue Exception => e
|
40
|
+
report_general_error("#{e} (#{e.backtrace.first})")
|
41
|
+
end
|
42
|
+
|
43
|
+
format_results
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
def init_results
|
49
|
+
@results = { :success => [], :error => [] }
|
50
|
+
end
|
51
|
+
|
52
|
+
def with_transaction(&block)
|
53
|
+
if RailsAdminImport.config.rollback_on_error &&
|
54
|
+
defined?(ActiveRecord)
|
55
|
+
|
56
|
+
ActiveRecord::Base.transaction &block
|
57
|
+
else
|
58
|
+
block.call
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def rollback_if_error
|
63
|
+
if RailsAdminImport.config.rollback_on_error &&
|
64
|
+
defined?(ActiveRecord) &&
|
65
|
+
!results[:error].empty?
|
66
|
+
|
67
|
+
results[:success] = []
|
68
|
+
raise ActiveRecord::Rollback
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def import_record(record)
|
73
|
+
perform_model_callback(import_model.model, :before_import_find, record)
|
74
|
+
|
75
|
+
if update_lookup && !(update_lookup - record.keys).empty?
|
76
|
+
raise UpdateLookupError, I18n.t("admin.import.missing_update_lookup")
|
77
|
+
end
|
78
|
+
|
79
|
+
object = find_or_create_object(record, update_lookup)
|
80
|
+
return if object.nil?
|
81
|
+
action = object.new_record? ? :create : :update
|
82
|
+
|
83
|
+
begin
|
84
|
+
perform_model_callback(object, :before_import_associations, record)
|
85
|
+
import_single_association_data(object, record)
|
86
|
+
import_many_association_data(object, record)
|
87
|
+
rescue AssociationNotFound => e
|
88
|
+
error = I18n.t("admin.import.association_not_found", :error => e.to_s)
|
89
|
+
report_error(object, action, error)
|
90
|
+
perform_model_callback(object, :after_import_association_error, record)
|
91
|
+
return
|
92
|
+
end
|
93
|
+
|
94
|
+
perform_model_callback(object, :before_import_save, record)
|
95
|
+
|
96
|
+
if object.save
|
97
|
+
report_success(object, action)
|
98
|
+
perform_model_callback(object, :after_import_save, record)
|
99
|
+
else
|
100
|
+
report_error(object, action, object.errors.full_messages.join(", "))
|
101
|
+
perform_model_callback(object, :after_import_error, record)
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
def update_lookup
|
106
|
+
@update_lookup ||= if params[:update_if_exists] == "1"
|
107
|
+
params[:update_lookup].map(&:to_sym)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
attr_reader :results
|
112
|
+
|
113
|
+
def logger
|
114
|
+
@logger ||= ImportLogger.new
|
115
|
+
end
|
116
|
+
|
117
|
+
def report_success(object, action)
|
118
|
+
object_label = import_model.label_for_model(object)
|
119
|
+
message = I18n.t("admin.import.import_success.#{action}",
|
120
|
+
:name => object_label)
|
121
|
+
logger.info "#{Time.now}: #{message}"
|
122
|
+
results[:success] << message
|
123
|
+
end
|
124
|
+
|
125
|
+
def report_error(object, action, error)
|
126
|
+
object_label = import_model.label_for_model(object)
|
127
|
+
message = I18n.t("admin.import.import_error.#{action}",
|
128
|
+
:name => object_label,
|
129
|
+
:error => error)
|
130
|
+
logger.info "#{Time.now}: #{message}"
|
131
|
+
results[:error] << message
|
132
|
+
end
|
133
|
+
|
134
|
+
def report_general_error(error)
|
135
|
+
message = I18n.t("admin.import.import_error.general", :error => error)
|
136
|
+
logger.info "#{Time.now}: #{message}"
|
137
|
+
results[:error] << message
|
138
|
+
end
|
139
|
+
|
140
|
+
def format_results
|
141
|
+
imported = results[:success]
|
142
|
+
not_imported = results[:error]
|
143
|
+
unless imported.empty?
|
144
|
+
results[:success_message] = format_result_message("successful", imported)
|
145
|
+
end
|
146
|
+
unless not_imported.empty?
|
147
|
+
results[:error_message] = format_result_message("error", not_imported)
|
148
|
+
end
|
149
|
+
|
150
|
+
results
|
151
|
+
end
|
152
|
+
|
153
|
+
def format_result_message(type, array)
|
154
|
+
result_count = "#{array.size} #{import_model.display_name.pluralize(array.size)}"
|
155
|
+
I18n.t("admin.flash.#{type}",
|
156
|
+
name: result_count,
|
157
|
+
action: I18n.t("admin.actions.import.done"))
|
158
|
+
end
|
159
|
+
|
160
|
+
def perform_model_callback(object, method_name, record)
|
161
|
+
if object.respond_to?(method_name)
|
162
|
+
# Compatibility: Old import hook took 2 arguments.
|
163
|
+
# Warn and call with a blank hash as 2nd argument.
|
164
|
+
if object.method(method_name).arity == 2
|
165
|
+
report_old_import_hook(method_name)
|
166
|
+
object.send(method_name, record, {})
|
167
|
+
else
|
168
|
+
object.send(method_name, record)
|
169
|
+
end
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
def report_old_import_hook(method_name)
|
174
|
+
unless @old_import_hook_reported
|
175
|
+
error = I18n.t("admin.import.import_error.old_import_hook",
|
176
|
+
model: import_model.display_name,
|
177
|
+
method: method_name)
|
178
|
+
report_general_error(error)
|
179
|
+
@old_import_hook_reported = true
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
def perform_global_callback(method_name)
|
184
|
+
object = import_model.model
|
185
|
+
object.send(method_name) if object.respond_to?(method_name)
|
186
|
+
end
|
187
|
+
|
188
|
+
def find_or_create_object(record, update)
|
189
|
+
field_names = import_model.model_fields.map(&:name)
|
190
|
+
new_attrs = record.select do |field_name, value|
|
191
|
+
field_names.include?(field_name) && !value.blank?
|
192
|
+
end
|
193
|
+
|
194
|
+
model = import_model.model
|
195
|
+
object = if update.present?
|
196
|
+
query = update.each_with_object({}) do
|
197
|
+
|field, query| query[field] = record[field]
|
198
|
+
end
|
199
|
+
model.where(query).first
|
200
|
+
end
|
201
|
+
|
202
|
+
if object.nil?
|
203
|
+
object = model.new
|
204
|
+
perform_model_callback(object, :before_import_attributes, record)
|
205
|
+
object.attributes = new_attrs
|
206
|
+
else
|
207
|
+
perform_model_callback(object, :before_import_attributes, record)
|
208
|
+
object.attributes = new_attrs.except(update.map(&:to_sym))
|
209
|
+
end
|
210
|
+
object
|
211
|
+
end
|
212
|
+
|
213
|
+
def import_single_association_data(object, record)
|
214
|
+
import_model.single_association_fields.each do |field|
|
215
|
+
mapping_key = params[:associations][field.name]
|
216
|
+
value = extract_mapping(record[field.name], mapping_key)
|
217
|
+
|
218
|
+
if !value.blank?
|
219
|
+
object.send "#{field.name}=", import_model.associated_object(field, mapping_key, value)
|
220
|
+
end
|
221
|
+
end
|
222
|
+
end
|
223
|
+
|
224
|
+
def import_many_association_data(object, record)
|
225
|
+
import_model.many_association_fields.each do |field|
|
226
|
+
if record.has_key? field.name
|
227
|
+
mapping_key = params[:associations][field.name]
|
228
|
+
values = record[field.name].reject { |value| value.blank? }.map { |value|
|
229
|
+
extract_mapping(value, mapping_key)
|
230
|
+
}
|
231
|
+
|
232
|
+
if !values.empty?
|
233
|
+
associated = values.map { |value| import_model.associated_object(field, mapping_key, value) }
|
234
|
+
object.send "#{field.name}=", associated
|
235
|
+
end
|
236
|
+
end
|
237
|
+
end
|
238
|
+
end
|
239
|
+
|
240
|
+
def extract_mapping(value, mapping_key)
|
241
|
+
if value.is_a? Hash
|
242
|
+
value[mapping_key]
|
243
|
+
else
|
244
|
+
value
|
245
|
+
end
|
246
|
+
end
|
247
|
+
end
|
248
|
+
end
|