solidus_import_products 2.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (30) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +19 -0
  3. data/README.md +71 -0
  4. data/app/controllers/spree/admin/product_imports_controller.rb +35 -0
  5. data/app/jobs/import_products_job.rb +14 -0
  6. data/app/models/solidus_import_products/parser.rb +15 -0
  7. data/app/models/solidus_import_products/parser/base.rb +24 -0
  8. data/app/models/solidus_import_products/parser/csv.rb +98 -0
  9. data/app/models/spree/product_import.rb +82 -0
  10. data/app/overrides/add_import_to_admin_sidebar_menu.rb +6 -0
  11. data/app/services/solidus_import_products/create_variant.rb +109 -0
  12. data/app/services/solidus_import_products/import.rb +38 -0
  13. data/app/services/solidus_import_products/process_row.rb +93 -0
  14. data/app/services/solidus_import_products/save_product.rb +41 -0
  15. data/app/services/solidus_import_products/save_properties.rb +23 -0
  16. data/app/services/solidus_import_products/update_product.rb +45 -0
  17. data/app/views/spree/admin/product_imports/index.html.erb +71 -0
  18. data/app/views/spree/admin/product_imports/show.html.erb +37 -0
  19. data/app/views/spree/admin/shared/_import_sidebar_menu.erb +3 -0
  20. data/app/views/spree/user_mailer/product_import_results.text.erb +23 -0
  21. data/lib/generators/solidus_import_products/install/install_generator.rb +41 -0
  22. data/lib/generators/solidus_import_products/install/templates/config/initializers/solidus_import_product_settings.rb +10 -0
  23. data/lib/solidus_import_products.rb +8 -0
  24. data/lib/solidus_import_products/engine.rb +26 -0
  25. data/lib/solidus_import_products/exception.rb +19 -0
  26. data/lib/solidus_import_products/import_helper.rb +118 -0
  27. data/lib/solidus_import_products/logger.rb +15 -0
  28. data/lib/solidus_import_products/user_mailer_ext.rb +15 -0
  29. data/lib/tasks/solidus_import_products.rake +1 -0
  30. 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,3 @@
1
+ <% if can? :admin, Spree::ProductImport %>
2
+ <%= tab :product_imports, url: spree.admin_product_imports_url %>
3
+ <% end %>
@@ -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'