rails_admin_import 0.1.9 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,75 @@
1
+ require "csv"
2
+ require "rchardet"
3
+
4
+ module RailsAdminImport
5
+ module Formats
6
+ class CSVImporter < FileImporter
7
+ Formats.register(:csv, self)
8
+
9
+ # Default is to downcase headers and add underscores to convert into attribute names
10
+ HEADER_CONVERTER = lambda do |header|
11
+ header.parameterize.underscore
12
+ end
13
+
14
+ def initialize(import_model, params)
15
+ super
16
+ @encoding = params[:encoding]
17
+ @header_converter = RailsAdminImport.config.header_converter || HEADER_CONVERTER
18
+ end
19
+
20
+ # A method that yields a hash of attributes for each record to import
21
+ def each_record
22
+ CSV.foreach(filename, csv_options) do |row|
23
+ yield convert_to_attributes(row)
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def csv_options
30
+ {
31
+ headers: true,
32
+ header_converters: @header_converter
33
+ }.tap do |options|
34
+ add_encoding!(options)
35
+ end
36
+ end
37
+
38
+ def add_encoding!(options)
39
+ from_encoding =
40
+ if !@encoding.blank?
41
+ @encoding
42
+ else
43
+ detect_encoding
44
+ end
45
+
46
+ to_encoding = import_model.abstract_model.encoding
47
+ if from_encoding && from_encoding != to_encoding
48
+ options[:encoding] = "#{from_encoding}:#{to_encoding}"
49
+ end
50
+ end
51
+
52
+ def detect_encoding
53
+ charset = CharDet.detect File.read(filename)
54
+ if charset["confidence"] > 0.6
55
+ from_encoding = charset["encoding"]
56
+ from_encoding = "UTF-8" if from_encoding == "ascii"
57
+ end
58
+ from_encoding
59
+ end
60
+
61
+ def convert_to_attributes(row)
62
+ row.each_with_object({}) do |(field, value), record|
63
+ break if field.nil?
64
+ field = field.to_sym
65
+ if import_model.has_multiple_values?(field)
66
+ field = import_model.pluralize_field(field)
67
+ (record[field] ||= []) << value
68
+ else
69
+ record[field] = value
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,16 @@
1
+ module RailsAdminImport
2
+ module Formats
3
+ class DummyImporter
4
+ def initialize(*)
5
+ end
6
+
7
+ def valid?
8
+ false
9
+ end
10
+
11
+ def error
12
+ I18n.t("admin.import.invalid_format")
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,45 @@
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
+ copy_path = File.join(Rails.root, "log", "import", copy_filename)
41
+ FileUtils.copy(filename, copy_path)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,31 @@
1
+ module RailsAdminImport
2
+ module Formats
3
+ class JSONImporter < FileImporter
4
+ Formats.register(:json, self)
5
+
6
+ # A method that yields a hash of attributes for each record to import
7
+ def each_record
8
+ File.open(filename) do |file|
9
+ data = JSON.load(file)
10
+
11
+ if data.is_a? Hash
12
+ # Load array from root key
13
+ data = data[root_key]
14
+ end
15
+
16
+ if !data.is_a? Array
17
+ raise ArgumentError, I18n.t("admin.import.invalid_json", root_key: root_key)
18
+ end
19
+
20
+ data.each do |record|
21
+ yield record.symbolize_keys
22
+ end
23
+ end
24
+ end
25
+
26
+ def root_key
27
+ import_model.model.model_name.element.pluralize
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,28 @@
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 for(format, *args)
9
+ @registry.fetch(format.to_s, DummyImporter).new(*args)
10
+ end
11
+
12
+ def all
13
+ @registry.keys
14
+ end
15
+
16
+ def reset
17
+ @registry = {}
18
+ end
19
+ end
20
+
21
+ reset
22
+ end
23
+ end
24
+
25
+ require "rails_admin_import/formats/dummy_importer"
26
+ require "rails_admin_import/formats/file_importer"
27
+ require "rails_admin_import/formats/csv_importer"
28
+ require "rails_admin_import/formats/json_importer"
@@ -2,13 +2,16 @@ module RailsAdminImport
2
2
  class ImportLogger
3
3
  attr_reader :logger
4
4
 
5
- def initialize(log_file_name = 'rails_admin_import.log')
6
- @logger = Logger.new("#{Rails.root}/log/#{log_file_name}") if RailsAdminImport.config.logging
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
7
9
  end
8
10
 
9
11
  def info(message)
10
- @logger.info message if RailsAdminImport.config.logging
12
+ if RailsAdminImport.config.logging
13
+ @logger.info message
14
+ end
11
15
  end
12
-
13
16
  end
14
17
  end
@@ -0,0 +1,94 @@
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 belongs_to_fields
48
+ @belongs_to_fields ||= association_fields.select { |f|
49
+ !f.multiple?
50
+ }
51
+ end
52
+
53
+ def many_fields
54
+ @many_fields ||= association_fields.select {
55
+ |f| f.multiple?
56
+ }
57
+ end
58
+
59
+ def associated_object(field, mapping_field, value)
60
+ klass = association_class(field)
61
+ klass.where(mapping_field => value).first or
62
+ raise AssociationNotFound, "#{klass}.#{mapping_field} = #{value}"
63
+ end
64
+
65
+ def association_class(field)
66
+ field.association.klass
67
+ end
68
+
69
+ def associated_config(field)
70
+ field.associated_model_config.import
71
+ end
72
+
73
+ def associated_model_fields(field)
74
+ @associated_fields ||= {}
75
+ @associated_fields[field] ||= associated_config(field).visible_fields.select { |f|
76
+ !f.association?
77
+ }
78
+ end
79
+
80
+ def has_multiple_values?(field_name)
81
+ plural_name = pluralize_field(field_name)
82
+ many_fields.any? { |field| field.name == field_name || field.name == plural_name }
83
+ end
84
+
85
+ def pluralize_field(field_name)
86
+ @plural_fields ||= many_fields.map(&:name).each_with_object({}) { |name, h|
87
+ h[name.to_s.singularize.to_sym] = name
88
+ }
89
+ @plural_fields[field_name] || field_name
90
+ end
91
+ end
92
+ end
93
+
94
+
@@ -0,0 +1,224 @@
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
+ # TODO: re-implement file size check
20
+ # if file_check.readlines.size > RailsAdminImport.config.line_item_limit
21
+ # return results = { :success => [], :error => ["Please limit upload file to #{RailsAdminImport.config.line_item_limit} line items."] }
22
+ # end
23
+
24
+ with_transaction do
25
+ records.each do |record|
26
+ import_record(record)
27
+ end
28
+
29
+ rollback_if_error
30
+ end
31
+ rescue Exception => e
32
+ report_general_error("#{e} (#{e.backtrace.first})")
33
+ end
34
+
35
+ format_results
36
+ end
37
+
38
+ private
39
+
40
+ def init_results
41
+ @results = { :success => [], :error => [] }
42
+ end
43
+
44
+ def with_transaction(&block)
45
+ if RailsAdminImport.config.rollback_on_error &&
46
+ defined?(ActiveRecord)
47
+
48
+ ActiveRecord::Base.transaction &block
49
+ else
50
+ block.call
51
+ end
52
+ end
53
+
54
+ def rollback_if_error
55
+ if RailsAdminImport.config.rollback_on_error &&
56
+ defined?(ActiveRecord) &&
57
+ !results[:error].empty?
58
+
59
+ results[:success] = []
60
+ raise ActiveRecord::Rollback
61
+ end
62
+ end
63
+
64
+ def import_record(record)
65
+ if update_lookup && !record.has_key?(update_lookup)
66
+ raise UpdateLookupError, I18n.t("admin.import.missing_update_lookup")
67
+ end
68
+
69
+ object = find_or_create_object(record, update_lookup)
70
+ action = object.new_record? ? :create : :update
71
+
72
+ begin
73
+ import_belongs_to_data(object, record)
74
+ import_has_many_data(object, record)
75
+ rescue AssociationNotFound => e
76
+ error = I18n.t("admin.import.association_not_found", :error => e.to_s)
77
+ report_error(object, action, error)
78
+ return
79
+ end
80
+
81
+ perform_model_callback(object, :before_import_save, record)
82
+
83
+ if object.save
84
+ report_success(object, action)
85
+ perform_model_callback(object, :after_import_save, record)
86
+ else
87
+ report_error(object, action, object.errors.full_messages.join(", "))
88
+ end
89
+ end
90
+
91
+ def update_lookup
92
+ @update_lookup ||= if params[:update_if_exists] == "1"
93
+ params[:update_lookup].to_sym
94
+ end
95
+ end
96
+
97
+ attr_reader :results
98
+
99
+ def logger
100
+ @logger ||= ImportLogger.new
101
+ end
102
+
103
+ def report_success(object, action)
104
+ object_label = import_model.label_for_model(object)
105
+ message = I18n.t("admin.import.import_success.#{action}",
106
+ :name => object_label)
107
+ logger.info "#{Time.now}: #{message}"
108
+ results[:success] << message
109
+ end
110
+
111
+ def report_error(object, action, error)
112
+ object_label = import_model.label_for_model(object)
113
+ message = I18n.t("admin.import.import_error.#{action}",
114
+ :name => object_label,
115
+ :error => error)
116
+ logger.info "#{Time.now}: #{message}"
117
+ results[:error] << message
118
+ end
119
+
120
+ def report_general_error(error)
121
+ message = I18n.t("admin.import.import_error.general", :error => error)
122
+ logger.info "#{Time.now}: #{message}"
123
+ results[:error] << message
124
+ end
125
+
126
+ def format_results
127
+ imported = results[:success]
128
+ not_imported = results[:error]
129
+ unless imported.empty?
130
+ results[:success_message] = format_result_message("successful", imported)
131
+ end
132
+ unless not_imported.empty?
133
+ results[:error_message] = format_result_message("error", not_imported)
134
+ end
135
+
136
+ results
137
+ end
138
+
139
+ def format_result_message(type, array)
140
+ result_count = "#{array.size} #{import_model.display_name.pluralize(array.size)}"
141
+ I18n.t("admin.flash.#{type}",
142
+ name: result_count,
143
+ action: I18n.t("admin.actions.import.done"))
144
+ end
145
+
146
+ def perform_model_callback(object, method_name, record)
147
+ if object.respond_to?(method_name)
148
+ # Compatibility: Old import hook took 2 arguments.
149
+ # Warn and call with a blank hash as 2nd argument.
150
+ if object.method(method_name).arity == 2
151
+ report_old_import_hook(method_name)
152
+ object.send(method_name, record, {})
153
+ else
154
+ object.send(method_name, record)
155
+ end
156
+ end
157
+ end
158
+
159
+ def report_old_import_hook(method_name)
160
+ unless @old_import_hook_reported
161
+ error = I18n.t("admin.import.import_error.old_import_hook",
162
+ model: import_model.display_name,
163
+ method: method_name)
164
+ report_general_error(error)
165
+ @old_import_hook_reported = true
166
+ end
167
+ end
168
+
169
+ def find_or_create_object(record, update)
170
+ field_names = import_model.model_fields.map(&:name)
171
+ new_attrs = record.select do |field_name, value|
172
+ field_names.include?(field_name) && !value.blank?
173
+ end
174
+
175
+ model = import_model.model
176
+ object = if update.present?
177
+ model.find_by(update => record[update])
178
+ end
179
+
180
+ if object.nil?
181
+ object = model.new(new_attrs)
182
+ else
183
+ object.attributes = new_attrs.except(update.to_sym)
184
+ end
185
+ object
186
+ end
187
+
188
+ def import_belongs_to_data(object, record)
189
+ import_model.belongs_to_fields.each do |field|
190
+ mapping_key = params[:associations][field.name]
191
+ value = extract_mapping(record[field.name], mapping_key)
192
+
193
+ if !value.blank?
194
+ object.send "#{field.name}=", import_model.associated_object(field, mapping_key, value)
195
+ end
196
+ end
197
+ end
198
+
199
+ def import_has_many_data(object, record)
200
+ import_model.many_fields.each do |field|
201
+ if record.has_key? field.name
202
+ mapping_key = params[:associations][field.name]
203
+ values = record[field.name].reject { |value| value.blank? }.map { |value|
204
+ extract_mapping(value, mapping_key)
205
+ }
206
+
207
+ if !values.empty?
208
+ associated = values.map { |value| import_model.associated_object(field, mapping_key, value) }
209
+ object.send "#{field.name}=", associated
210
+ end
211
+ end
212
+ end
213
+ end
214
+
215
+ def extract_mapping(value, mapping_key)
216
+ if value.is_a? Hash
217
+ value[mapping_key]
218
+ else
219
+ value
220
+ end
221
+ end
222
+ end
223
+ end
224
+
@@ -0,0 +1,23 @@
1
+ # Load the Rails Admin gem if not already done
2
+ require "rails_admin"
3
+
4
+ # Add the Import action
5
+ require "rails_admin_import/action"
6
+
7
+ # Add the import configuration section for models
8
+ require "rails_admin_import/config/sections/import"
9
+
10
+ # Register the configuration adapter for Rails Admin
11
+ # to allow configure_with(:import)
12
+ module RailsAdminImport
13
+ module Extension
14
+ class ConfigurationAdapter < SimpleDelegator
15
+ def initialize
16
+ super RailsAdminImport::Config
17
+ end
18
+ end
19
+ end
20
+ end
21
+
22
+ RailsAdmin.add_extension :import, RailsAdminImport::Extension,
23
+ configuration: true
@@ -1,3 +1,3 @@
1
1
  module RailsAdminImport
2
- VERSION = "0.1.9"
2
+ VERSION = "1.0.0"
3
3
  end
@@ -1,12 +1,15 @@
1
1
  require "rails_admin_import/engine"
2
- require "rails_admin_import/import"
2
+ require "rails_admin_import/import_model"
3
+ require "rails_admin_import/formats"
4
+ require "rails_admin_import/importer"
3
5
  require "rails_admin_import/config"
6
+ require "rails_admin_import/rails_admin_plugin"
4
7
 
5
8
  module RailsAdminImport
6
9
  def self.config(entity = nil, &block)
7
10
  if entity
8
11
  RailsAdminImport::Config.model(entity, &block)
9
- elsif block_given? && ENV['SKIP_RAILS_ADMIN_INITIALIZER'] != "true"
12
+ elsif block_given? && ENV["SKIP_RAILS_ADMIN_INITIALIZER"] != "true"
10
13
  block.call(RailsAdminImport::Config)
11
14
  else
12
15
  RailsAdminImport::Config
@@ -18,40 +21,3 @@ module RailsAdminImport
18
21
  end
19
22
  end
20
23
 
21
- require 'rails_admin/config/actions'
22
-
23
- module RailsAdmin
24
- module Config
25
- module Actions
26
- class Import < Base
27
- RailsAdmin::Config::Actions.register(self)
28
-
29
- register_instance_option :collection do
30
- true
31
- end
32
-
33
- register_instance_option :http_methods do
34
- [:get, :post]
35
- end
36
-
37
- register_instance_option :controller do
38
- Proc.new do
39
- @response = {}
40
-
41
- if request.post?
42
- results = @abstract_model.model.run_import(params)
43
- @response[:notice] = results[:success].join("<br />").html_safe if results[:success].any?
44
- @response[:error] = results[:error].join("<br />").html_safe if results[:error].any?
45
- end
46
-
47
- render :action => @action.template_name
48
- end
49
- end
50
-
51
- register_instance_option :link_icon do
52
- 'icon-folder-open'
53
- end
54
- end
55
- end
56
- end
57
- end