solidus_import_products 2.0.1
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/LICENSE +19 -0
- data/README.md +71 -0
- data/app/controllers/spree/admin/product_imports_controller.rb +35 -0
- data/app/jobs/import_products_job.rb +14 -0
- data/app/models/solidus_import_products/parser.rb +15 -0
- data/app/models/solidus_import_products/parser/base.rb +24 -0
- data/app/models/solidus_import_products/parser/csv.rb +98 -0
- data/app/models/spree/product_import.rb +82 -0
- data/app/overrides/add_import_to_admin_sidebar_menu.rb +6 -0
- data/app/services/solidus_import_products/create_variant.rb +109 -0
- data/app/services/solidus_import_products/import.rb +38 -0
- data/app/services/solidus_import_products/process_row.rb +93 -0
- data/app/services/solidus_import_products/save_product.rb +41 -0
- data/app/services/solidus_import_products/save_properties.rb +23 -0
- data/app/services/solidus_import_products/update_product.rb +45 -0
- data/app/views/spree/admin/product_imports/index.html.erb +71 -0
- data/app/views/spree/admin/product_imports/show.html.erb +37 -0
- data/app/views/spree/admin/shared/_import_sidebar_menu.erb +3 -0
- data/app/views/spree/user_mailer/product_import_results.text.erb +23 -0
- data/lib/generators/solidus_import_products/install/install_generator.rb +41 -0
- data/lib/generators/solidus_import_products/install/templates/config/initializers/solidus_import_product_settings.rb +10 -0
- data/lib/solidus_import_products.rb +8 -0
- data/lib/solidus_import_products/engine.rb +26 -0
- data/lib/solidus_import_products/exception.rb +19 -0
- data/lib/solidus_import_products/import_helper.rb +118 -0
- data/lib/solidus_import_products/logger.rb +15 -0
- data/lib/solidus_import_products/user_mailer_ext.rb +15 -0
- data/lib/tasks/solidus_import_products.rake +1 -0
- metadata +255 -0
@@ -0,0 +1,38 @@
|
|
1
|
+
module SolidusImportProducts
|
2
|
+
class Import
|
3
|
+
attr_accessor :product_imports, :logger
|
4
|
+
|
5
|
+
def initialize(args = { product_imports: nil })
|
6
|
+
self.product_imports = args[:product_imports]
|
7
|
+
self.logger = SolidusImportProducts::Logger.instance
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.call(options = {})
|
11
|
+
new(options).call
|
12
|
+
end
|
13
|
+
|
14
|
+
def call
|
15
|
+
skus_of_products_before_import = Spree::Product.all.map(&:sku)
|
16
|
+
parser = product_imports.parse
|
17
|
+
col = parser.column_mappings
|
18
|
+
|
19
|
+
product_imports.start
|
20
|
+
ActiveRecord::Base.transaction do
|
21
|
+
parser.data_rows.each do |row|
|
22
|
+
SolidusImportProducts::ProcessRow.call(
|
23
|
+
parser: parser,
|
24
|
+
product_imports: product_imports,
|
25
|
+
row: row,
|
26
|
+
col: col,
|
27
|
+
skus_of_products_before_import: skus_of_products_before_import
|
28
|
+
)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
product_imports.complete
|
33
|
+
rescue SolidusImportProducts::Exception::Base => e
|
34
|
+
product_imports.failure!
|
35
|
+
raise e
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,93 @@
|
|
1
|
+
module SolidusImportProducts
|
2
|
+
class ProcessRow
|
3
|
+
attr_accessor :parser, :product_imports, :logger, :row, :col, :product_information, :variant_field, :skus_of_products_before_import
|
4
|
+
|
5
|
+
VARIANT_FIELD_NAME = :name
|
6
|
+
|
7
|
+
def initialize(args = { parser: nil, product_imports: nil, row: nil, col: nil, skus_of_products_before_import: nil })
|
8
|
+
self.parser = args[:parser]
|
9
|
+
self.product_imports = args[:product_imports]
|
10
|
+
self.row = args[:row]
|
11
|
+
self.col = args[:col]
|
12
|
+
self.variant_field = VARIANT_FIELD_NAME
|
13
|
+
self.skus_of_products_before_import = args[:skus_of_products_before_import]
|
14
|
+
self.logger = SolidusImportProducts::Logger.instance
|
15
|
+
self.product_information = { variant_options: {}, images: [], variant_images: [], product_properties: {}, attributes: {} }
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.call(options = {})
|
19
|
+
new(options).call
|
20
|
+
end
|
21
|
+
|
22
|
+
def call
|
23
|
+
extract_product_information
|
24
|
+
product_information_default_values
|
25
|
+
logger.log(product_information.to_s, :debug)
|
26
|
+
|
27
|
+
variant_column = col[variant_field]
|
28
|
+
product = Spree::Product.find_by(variant_field.to_s => row[variant_column])
|
29
|
+
|
30
|
+
unless product
|
31
|
+
if skus_of_products_before_import.include?(product_information[:attributes][:sku])
|
32
|
+
raise SolidusImportProducts::Exception::ProductError, "SKU #{product_information[:attributes][:sku]} exists, but #{variant_field}: #{row[variant_column]} not exists!! "
|
33
|
+
end
|
34
|
+
product = Spree::Product.new
|
35
|
+
end
|
36
|
+
|
37
|
+
unless product_imports.product?(product)
|
38
|
+
create_or_update_product(product)
|
39
|
+
product_imports.add_product(product)
|
40
|
+
end
|
41
|
+
|
42
|
+
SolidusImportProducts::CreateVariant.call(product: product, product_information: product_information)
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
def create_or_update_product(product)
|
48
|
+
properties_hash = SolidusImportProducts::UpdateProduct.call(product: product, product_information: product_information)
|
49
|
+
SolidusImportProducts::SaveProduct.call(product: product, product_information: product_information)
|
50
|
+
SolidusImportProducts::SaveProperties.call(product: product, properties_hash: properties_hash)
|
51
|
+
end
|
52
|
+
|
53
|
+
def extract_product_information
|
54
|
+
col.each do |key, value|
|
55
|
+
row[value].try :strip!
|
56
|
+
if parser.variant_option_field?(key)
|
57
|
+
product_information[:variant_options][key] = row[value]
|
58
|
+
elsif parser.property_field?(key)
|
59
|
+
product_information[:product_properties][key] = row[value]
|
60
|
+
elsif parser.image_field?(key)
|
61
|
+
product_information[:images].push(row[value])
|
62
|
+
elsif parser.variant_image_field?(key)
|
63
|
+
product_information[:variant_images].push(row[value])
|
64
|
+
else
|
65
|
+
product_information[:attributes][key] = key.to_s.eql?('price') ? convert_to_price(row[value]) : row[value]
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def product_information_default_values
|
71
|
+
product_information[:attributes][:available_on] = Time.zone.today - 1.day if product_information[:attributes][:available_on].nil?
|
72
|
+
|
73
|
+
if product_information[:attributes][:shipping_category_id].nil?
|
74
|
+
sc = Spree::ShippingCategory.first
|
75
|
+
product_information[:attributes][:shipping_category_id] = sc.id if sc
|
76
|
+
end
|
77
|
+
|
78
|
+
product_information[:attributes][:retail_only] = 0 if product_information[:attributes][:retail_only].nil?
|
79
|
+
end
|
80
|
+
|
81
|
+
# Special process of prices because of locales and different decimal separator characters.
|
82
|
+
# We want to get a format with dot as decimal separator and without thousand separator
|
83
|
+
def convert_to_price(price_str)
|
84
|
+
raise SolidusImportProducts::Exception::InvalidPrice unless price_str
|
85
|
+
punt = price_str.index('.')
|
86
|
+
coma = price_str.index(',')
|
87
|
+
if !coma.nil? && !punt.nil?
|
88
|
+
price_str.gsub!(punt < coma ? '.' : ',', '')
|
89
|
+
end
|
90
|
+
price_str.tr(',', '.').to_f
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
module SolidusImportProducts
|
2
|
+
class SaveProduct
|
3
|
+
attr_accessor :product, :product_information, :logger
|
4
|
+
|
5
|
+
include SolidusImportProducts::ImportHelper
|
6
|
+
|
7
|
+
def self.call(options = {})
|
8
|
+
new.call(options)
|
9
|
+
end
|
10
|
+
|
11
|
+
def call(args = { product: nil, product_information: nil })
|
12
|
+
self.logger = SolidusImportProducts::Logger.instance
|
13
|
+
self.product_information = args[:product_information]
|
14
|
+
self.product = args[:product]
|
15
|
+
|
16
|
+
logger.log("SAVE PRODUCT: #{product.inspect}", :debug)
|
17
|
+
|
18
|
+
unless product.valid?
|
19
|
+
msg = "A product could not be imported - here is the information we have:\n" \
|
20
|
+
"#{product_information}, #{product.inspect} #{product.errors.full_messages.join(', ')}"
|
21
|
+
logger.log(msg, :error)
|
22
|
+
raise SolidusImportProducts::Exception::ProductError, msg
|
23
|
+
end
|
24
|
+
|
25
|
+
product.save
|
26
|
+
|
27
|
+
# Associate our new product with any taxonomies that we need to worry about
|
28
|
+
if product_information[:attributes].key?(:taxonomies) && product_information[:attributes][:taxonomies]
|
29
|
+
associate_product_with_taxon(product, product_information[:attributes][:taxonomies], true)
|
30
|
+
end
|
31
|
+
|
32
|
+
# Finally, attach any images that have been specified
|
33
|
+
product_information[:images].each do |filename|
|
34
|
+
find_and_attach_image_to(product, filename)
|
35
|
+
end
|
36
|
+
|
37
|
+
logger.log("#{product.name} successfully imported.\n")
|
38
|
+
true
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module SolidusImportProducts
|
2
|
+
class SaveProperties
|
3
|
+
attr_accessor :product, :properties_hash, :logger
|
4
|
+
|
5
|
+
def self.call(options = {})
|
6
|
+
new.call(options)
|
7
|
+
end
|
8
|
+
|
9
|
+
def call(args = { product: nil, properties_hash: nil })
|
10
|
+
self.logger = SolidusImportProducts::Logger.instance
|
11
|
+
self.properties_hash = args[:properties_hash]
|
12
|
+
self.product = args[:product]
|
13
|
+
|
14
|
+
properties_hash.each do |field, value|
|
15
|
+
property = Spree::Property.where('lower(name) = ?', field).first
|
16
|
+
next unless property
|
17
|
+
product_property = Spree::ProductProperty.where(product_id: product.id, property_id: property.id).first_or_initialize
|
18
|
+
product_property.value = value
|
19
|
+
product_property.save!
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
module SolidusImportProducts
|
2
|
+
class UpdateProduct
|
3
|
+
attr_accessor :product, :product_information, :logger
|
4
|
+
|
5
|
+
include SolidusImportProducts::ImportHelper
|
6
|
+
|
7
|
+
def self.call(options = {})
|
8
|
+
new.call(options)
|
9
|
+
end
|
10
|
+
|
11
|
+
def call(args = { product: nil, product_information: nil })
|
12
|
+
self.logger = SolidusImportProducts::Logger.instance
|
13
|
+
self.product_information = args[:product_information]
|
14
|
+
self.product = args[:product]
|
15
|
+
|
16
|
+
logger.log("UPDATE PRODUCT: #{product.inspect} #{product_information.inspect}", :debug)
|
17
|
+
|
18
|
+
product.update_attribute(:deleted_at, nil) if product.deleted_at
|
19
|
+
product.variants.each { |variant| variant.update_attribute(:deleted_at, nil) }
|
20
|
+
|
21
|
+
properties_hash = {}
|
22
|
+
|
23
|
+
product_information.each do |field, value|
|
24
|
+
if field == :product_properties
|
25
|
+
value.each { |prop_field, prop_value| properties_hash[prop_field] = prop_value }
|
26
|
+
elsif field == :attributes
|
27
|
+
value.each { |attr_field, attr_value| product.send("#{attr_field}=", attr_value) if product.respond_to?("#{attr_field}=") }
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
setup_shipping_category(product) unless product.shipping_category
|
32
|
+
|
33
|
+
properties_hash
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def setup_shipping_category(product)
|
39
|
+
unless Spree::ShippingCategory.first
|
40
|
+
Spree::ShippingCategory.find_or_create_by(name: 'Default')
|
41
|
+
end
|
42
|
+
product.shipping_category = Spree::ShippingCategory.first
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
<% content_for :page_title do %>
|
2
|
+
<%= t('form.product_import.heading') %>
|
3
|
+
<% end %>
|
4
|
+
|
5
|
+
<div class="row">
|
6
|
+
<div class="col-sm-6">
|
7
|
+
<%= form_for([:admin, @product_import], :method => :post, :html => { :multipart => true }) do |f| %>
|
8
|
+
<fieldset class="no-border-top">
|
9
|
+
<%= f.field_container :data_file, class: ['form-group'] do %>
|
10
|
+
<%= f.label :data_file, t('form.product_import.new.data_file') %><br />
|
11
|
+
<%= f.file_field :data_file %>
|
12
|
+
<%= f.error_message_on :data_file %>
|
13
|
+
<% end %>
|
14
|
+
<div class="field">
|
15
|
+
<%= f.label :separatorChar, t('form.product_import.separator_char') %><br />
|
16
|
+
<%= f.text_field :separatorChar, class: 'form-control required',:id => "separatorChar",:value => ";" ,:size => 1, :maxlength => 1 %>
|
17
|
+
</div>
|
18
|
+
<div class="field">
|
19
|
+
<%= f.label :encoding_csv, t('form.product_import.encoding') %><br />
|
20
|
+
<%= f.select(:encoding_csv,
|
21
|
+
options_from_collection_for_select(Spree::ProductImport::ENCODINGS, :to_s, :to_s, 'UTF-8'),
|
22
|
+
{:class => 'select2', :prompt => false}) %>
|
23
|
+
</div>
|
24
|
+
<div data-hook="buttons" class="form-actions">
|
25
|
+
<%= button Spree.t('actions.import'), 'ok', 'submit', {class: 'btn-success'} %>
|
26
|
+
</div>
|
27
|
+
</fieldset>
|
28
|
+
|
29
|
+
<% end %>
|
30
|
+
</div>
|
31
|
+
</div>
|
32
|
+
<table class="table">
|
33
|
+
<colgroup>
|
34
|
+
<col style="width: 15%">
|
35
|
+
<col style="width: 20%">
|
36
|
+
<col style="width: 10%">
|
37
|
+
<col style="width: 15%">
|
38
|
+
<col style="width: 10%">
|
39
|
+
<col style="width: 20%">
|
40
|
+
<col style="width: 5%">
|
41
|
+
</colgroup>
|
42
|
+
<thead>
|
43
|
+
<tr>
|
44
|
+
<th>Date creation</th>
|
45
|
+
<th>CSV Name</th>
|
46
|
+
<th>Status</th>
|
47
|
+
<th>Date Status</th>
|
48
|
+
<th>Imported</th>
|
49
|
+
<th>Error Message</th>
|
50
|
+
<th class="actions">Actions</th>
|
51
|
+
</tr>
|
52
|
+
</thead>
|
53
|
+
<tbody>
|
54
|
+
<% Spree::ProductImport.order("created_at DESC").all.each do |import| %>
|
55
|
+
<tr class="<%= cycle('odd', 'even') %>" id="<%= dom_id import %>">
|
56
|
+
<td><%= I18n.l(import.created_at.to_datetime,format: :short)%></td>
|
57
|
+
<td><%= link_to import.data_file_file_name, import.data_file.url -%></td>
|
58
|
+
<td><span class="label-<%= import.state.downcase %>"><%= t(import.state, :scope => "product_import.state")%></span></td>
|
59
|
+
<td><%= I18n.l(import.state_datetime.to_datetime,format: :short) %></td>
|
60
|
+
<td><%= import.product_ids.size -%></td>
|
61
|
+
<td><%= import.error_message%></td>
|
62
|
+
<td class="actions text-center">
|
63
|
+
<%= link_to_edit import, :url => admin_product_import_path(import), :no_text => true -%>
|
64
|
+
</td>
|
65
|
+
<td class="actions text-center">
|
66
|
+
<%= link_to_delete import, :url => admin_product_import_path(import), :no_text => true -%>
|
67
|
+
</td>
|
68
|
+
</tr>
|
69
|
+
<% end %>
|
70
|
+
</tbody>
|
71
|
+
</table>
|
@@ -0,0 +1,37 @@
|
|
1
|
+
<% content_for :page_title do %>
|
2
|
+
<%= t(:show_product_import) %>
|
3
|
+
<% end %>
|
4
|
+
|
5
|
+
<% content_for :page_actions do %>
|
6
|
+
<%= button_link_to t(:back_to_product_imports), admin_product_imports_path, :icon => 'icon-arrow-left' %>
|
7
|
+
<%= button_link_to t(:delete_product_import), admin_product_import_path(@product_import), {:icon => 'icon-remove', :method => :delete, :confirm => t("confirm_delete_product_import")} %>
|
8
|
+
<% end %>
|
9
|
+
|
10
|
+
<table class="table" id="listing_products">
|
11
|
+
<colgroup>
|
12
|
+
<col style="width: 20%">
|
13
|
+
<col style="width: 50%">
|
14
|
+
<col style="width: 20%">
|
15
|
+
<col style="width: 10%">
|
16
|
+
</colgroup>
|
17
|
+
<thead>
|
18
|
+
<tr data-hook="admin_products_index_headers">
|
19
|
+
<th><%= t(:sku) %></th>
|
20
|
+
<th><%= t(:name) %></th>
|
21
|
+
<th><%= t(:master_price) %></th>
|
22
|
+
<th class="actions" data-hook="admin_products_index_header_actions"></th>
|
23
|
+
</tr>
|
24
|
+
</thead>
|
25
|
+
<tbody>
|
26
|
+
<% @products.each do |product| %>
|
27
|
+
<tr class="<%= cycle('odd', 'even') %>" <%= "style='color: red;'" if product.deleted? %> id="<%= dom_id product %>" data-hook="admin_products_index_rows">
|
28
|
+
<td><%= product.sku rescue '' %></td>
|
29
|
+
<td><%= product.name rescue '' %></td>
|
30
|
+
<td><%= number_to_currency product.price rescue '' %></td>
|
31
|
+
<td class="actions" data-hook="admin_products_index_row_actions">
|
32
|
+
<%= link_to_edit_url edit_admin_product_path(product), :no_text => true unless product.deleted? %>
|
33
|
+
</td>
|
34
|
+
</tr>
|
35
|
+
<% end %>
|
36
|
+
</tbody>
|
37
|
+
</table>
|
@@ -0,0 +1,23 @@
|
|
1
|
+
Hello,
|
2
|
+
|
3
|
+
<% if @error_message %>
|
4
|
+
Something went wrong during the product import.
|
5
|
+
Common causes for this include:
|
6
|
+
- Badly formatted CSV
|
7
|
+
- Special characters within the file
|
8
|
+
- Incorrect data
|
9
|
+
- Application or database error
|
10
|
+
|
11
|
+
Please check your file and try again.
|
12
|
+
To help diagnose the problem, we have attached the log file for the imports.
|
13
|
+
|
14
|
+
--
|
15
|
+
|
16
|
+
<%= @error_message %>
|
17
|
+
|
18
|
+
<% else %>
|
19
|
+
This is a quick email to let you know that the product import you submitted has been completed.
|
20
|
+
You can now see the products you added visible in your store.
|
21
|
+
<% end %>
|
22
|
+
|
23
|
+
|
@@ -0,0 +1,41 @@
|
|
1
|
+
module SolidusImportProducts
|
2
|
+
module Generators
|
3
|
+
class InstallGenerator < Rails::Generators::Base
|
4
|
+
class_option :auto_run_migrations, type: :boolean, default: false
|
5
|
+
class_option :auto_skip_migrations, type: :boolean, default: false
|
6
|
+
|
7
|
+
def self.source_paths
|
8
|
+
paths = superclass.source_paths
|
9
|
+
paths << File.expand_path('../templates', "../../#{__FILE__}")
|
10
|
+
paths << File.expand_path('../templates', "../#{__FILE__}")
|
11
|
+
paths << File.expand_path('../templates', __FILE__)
|
12
|
+
paths.flatten
|
13
|
+
end
|
14
|
+
|
15
|
+
def add_migrations
|
16
|
+
if !options[:auto_skip_migrations]
|
17
|
+
run 'bundle exec rake solidus_import_products:install:migrations'
|
18
|
+
else
|
19
|
+
puts 'Skipping rake solidus_import_products:install:migrations, don\'t forget to run it!'
|
20
|
+
end
|
21
|
+
|
22
|
+
end
|
23
|
+
|
24
|
+
def add_files
|
25
|
+
template 'config/initializers/solidus_import_product_settings.rb', 'config/initializers/solidus_import_product_settings.rb'
|
26
|
+
end
|
27
|
+
|
28
|
+
def run_migrations
|
29
|
+
run_migrations = options[:auto_skip_migrations] ||
|
30
|
+
options[:auto_run_migrations] ||
|
31
|
+
['', 'y', 'Y'].include?(ask('Would you like to run the migrations now? [Y/n]'))
|
32
|
+
|
33
|
+
if run_migrations && !options[:auto_skip_migrations]
|
34
|
+
run 'bundle exec rake db:migrate'
|
35
|
+
else
|
36
|
+
puts 'Skipping rake db:migrate, don\'t forget to run it!'
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,10 @@
|
|
1
|
+
Spree::ProductImport.settings = {
|
2
|
+
num_prods_for_delayed: 20, # From this number of products, the process is executed in delayed_job. Under it is processed immediately.
|
3
|
+
create_missing_taxonomies: true,
|
4
|
+
product_image_path: "#{Rails.root}/lib/etc/product_data/product-images/", # The location of images on disk
|
5
|
+
log_to: File.join(Rails.root, '/log/', "import_products_#{Rails.env}.log"), # Where to log to
|
6
|
+
destroy_original_products: false, # Disabled #Delete the products originally in the database after the import?
|
7
|
+
create_variants: true, # Compares products and creates a variant if that product already exists.
|
8
|
+
store_field: :store_code, # Which field of the column mappings contains either the store id or store code?
|
9
|
+
transaction: true # import product in a sql transaction so we can rollback when an exception is raised
|
10
|
+
}
|
@@ -0,0 +1,8 @@
|
|
1
|
+
require 'solidus_core'
|
2
|
+
require 'solidus_support'
|
3
|
+
require 'solidus_auth_devise'
|
4
|
+
require 'deface'
|
5
|
+
require 'solidus_import_products/exception'
|
6
|
+
require 'solidus_import_products/logger'
|
7
|
+
require 'solidus_import_products/import_helper'
|
8
|
+
require 'solidus_import_products/engine'
|