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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 7cbe547a1f35bff8d13c359e012af39a7d6280bf
4
+ data.tar.gz: 5ce9aa54661c7b0069b68648350aeba1c5dabdd7
5
+ SHA512:
6
+ metadata.gz: 0506cd0571e1348fe37dfb2633b97fd537dd7c9e11f36cc007f446916f1cc2fa26e2f612ed16654993b77e8c3f196a0df1fcf0c634a163a7b8e5c13934ae1fe7
7
+ data.tar.gz: b68387bf007c5b6a616c9f335a1ad56a2a86e8ed78911e12dd81376d9e484f1ecf655b6db1f146eeb731a337077a40c641163874f4ef6480628510df43c72b4a
data/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2018 Angel Arancibia
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,71 @@
1
+ [![Gem Version](https://badge.fury.io/rb/solidus_import_products.svg)](https://badge.fury.io/rb/solidus_import_products)
2
+ [![Build Status](https://travis-ci.org/ngelx/solidus_import_products.svg?branch=master)](https://travis-ci.org/ngelx/solidus_import_products)
3
+ [![Maintainability](https://api.codeclimate.com/v1/badges/132ebaa254502b25d886/maintainability)](https://codeclimate.com/github/ngelx/solidus_import_products/maintainability) [![Test Coverage](https://api.codeclimate.com/v1/badges/132ebaa254502b25d886/test_coverage)](https://codeclimate.com/github/ngelx/solidus_import_products/test_coverage)
4
+ [![Dependency Status](https://beta.gemnasium.com/badges/github.com/ngelx/solidus_import_products.svg)](https://beta.gemnasium.com/projects/github.com/ngelx/solidus_import_products)
5
+
6
+ This extension adds product import functionality to Solidus, with a bunch of features that give it similar functionality to Shopify's importer.
7
+
8
+ This extension adds a tab to the administration area of Solidus, allowing a logged-in user to select and upload a file in any of the [supported formats](https://github.com/ngelx/solidus_import_products#Formats-supported) containing product information. The upload is then placed on queue for processing. Once it has been processed, the user who initiated the job is notified by email that their import has completed.
9
+
10
+
11
+ Features
12
+ ==============
13
+
14
+ Products
15
+ -------------
16
+ * Create if they do not exists, otherwise update them.
17
+ * Set Properties.
18
+ * Attach/import multiple local or remote images.
19
+ * Create/Associate to many taxonomies.
20
+
21
+ Variations
22
+ ------------
23
+ * Create if they do not exists, otherwise update them.
24
+ * Create Options types as needed.
25
+ * Create Options values as needed.
26
+
27
+ Formats supported
28
+ -----------
29
+ * CSV. [examples](spec/fixtures/)
30
+
31
+ ActiveJob
32
+ ----------
33
+ This gem relies on ActiveJob for scheduling imports greater than 20 products/variantions. For more information about activeJob see [ActiveJob Rails Guide](http://guides.rubyonrails.org/active_job_basics.html)
34
+
35
+
36
+ Installation
37
+ ==============
38
+ 1. Add the gem to your Gemfile, and run bundle install.
39
+    `gem 'solidus_import_products', :git => 'git://github.com/ngelx/solidus_import_products.git'` then `bundle install`
40
+
41
+ 2. rails generate solidus_import_products:install
42
+
43
+ 3. Configure the extension to suit your application by changing config variables in `config/initializers/solidus_import_product_settings.rb`
44
+
45
+ 4. Run application!
46
+
47
+ Sample files and documentation
48
+ ==============
49
+
50
+ Some basic samples files could be find in [spec/fixtures](spec/fixtures/)
51
+
52
+ For documentation refer to the [wiki](https://github.com/ngelx/solidus_import_products/wiki)
53
+
54
+ Contributing
55
+ =======
56
+
57
+ * Fork the project
58
+ * Make your changes, including tests that exercise the code. The first steps to setup the env is something like:
59
+
60
+ ```ruby
61
+ rake test_app
62
+ rake spec
63
+ ```
64
+
65
+ * Summarize your changes in CHANGELOG.md
66
+ * Make a pull request
67
+
68
+ History and attribution
69
+ ==============
70
+ The product import script was based on a simple import script written by Brian Quinn [here](https://gist.github.com/31710). Then it was extended by Josh McArthur (2010). After that by [2beDigital team](https://github.com/2beDigital/solidus_import_products).
71
+ Finaly after all that, I've made some big estructural changes and here we are.
@@ -0,0 +1,35 @@
1
+ module Spree
2
+ module Admin
3
+ class ProductImportsController < BaseController
4
+ def index
5
+ @product_import = Spree::ProductImport.new
6
+ end
7
+
8
+ def show
9
+ @product_import = Spree::ProductImport.find(params[:id])
10
+ @products = @product_import.products
11
+ end
12
+
13
+ def create
14
+ @product_imports = spree_current_user.product_imports.create(product_import_params)
15
+ ImportProductsJob.perform_later(@product_imports)
16
+ flash[:notice] = t('product_import_processing')
17
+ redirect_to admin_product_imports_path
18
+ end
19
+
20
+ def destroy
21
+ @product_import = Spree::ProductImport.find(params[:id])
22
+ if @product_import.destroy
23
+ flash[:success] = t('delete_product_import_successful')
24
+ end
25
+ redirect_to admin_product_imports_path
26
+ end
27
+
28
+ private
29
+
30
+ def product_import_params
31
+ params.require(:product_import).permit!
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,14 @@
1
+ class ImportProductsJob < ApplicationJob
2
+ queue_as :default
3
+
4
+ def perform(product_imports)
5
+ user = product_imports.user
6
+ begin
7
+ SolidusImportProducts::Import.call(product_imports: product_imports)
8
+ Spree::UserMailer.product_import_results(user).deliver_later
9
+ rescue StandardError => exception
10
+ Rails.logger.error("[ActiveJob] [ImportProductsJob] [#{job_id}] ID: #{product_imports} #{exception}")
11
+ Spree::UserMailer.product_import_results(user, "#{exception.message} #{exception.backtrace.join('\n')}").deliver_later
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,15 @@
1
+ module SolidusImportProducts
2
+ module Parser
3
+ def parse(strategy, data_file, options)
4
+ if strategy == :csv
5
+ Parser::Csv.new(data_file, options)
6
+ else
7
+ raise SolidusImportProducts::Exception::InvalidParseStrategy
8
+ end
9
+ end
10
+
11
+ module_function :parse
12
+
13
+ # Seguir arreglando esto apra arriba. PAra abajo aprece estar aunque un poco ams de test no vendiran mal
14
+ end
15
+ end
@@ -0,0 +1,24 @@
1
+ module SolidusImportProducts
2
+ module Parser
3
+ class Base
4
+ attr_accessor :rows, :data_file, :image_fields, :variant_image_fields, :property_fields
5
+
6
+ # column_mappings
7
+ # This method attempts to automatically map headings in the CSV files
8
+ # with fields in the product and variant models.
9
+ # Rows[0] is an array of headings for columns - SKU, Master Price, etc.)
10
+ # @return a hash of symbol heading => column index pairs
11
+ def column_mappings
12
+ raise SolidusImportProducts::AbstractMthodCall
13
+ end
14
+
15
+ def data_rows
16
+ raise SolidusImportProducts::AbstractMthodCall
17
+ end
18
+
19
+ def products_count
20
+ raise SolidusImportProducts::AbstractMthodCall
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,98 @@
1
+ require 'csv'
2
+
3
+ module SolidusImportProducts
4
+ module Parser
5
+ class Csv < Base
6
+ DEFAULT_CSV_ENCODING = 'utf-8'.freeze
7
+ DEFAULT_CSV_SEPARATOR = ','.freeze
8
+
9
+ attr_accessor :variant_option_fields, :mappings
10
+
11
+ def initialize(data_file, options)
12
+ self.data_file = data_file
13
+ self.mappings = {}
14
+ self.variant_option_fields = []
15
+ self.image_fields = []
16
+ self.variant_image_fields = []
17
+ self.property_fields = []
18
+ encoding_csv = (options[:encoding_csv] if options) || DEFAULT_CSV_ENCODING
19
+ separator_char = (options[:separator_char] if options) || DEFAULT_CSV_SEPARATOR
20
+ csv_string = open(data_file, "r:#{encoding_csv}").read.encode('utf-8')
21
+ self.rows = CSV.parse(csv_string, col_sep: separator_char)
22
+ extract_column_mappings
23
+ end
24
+
25
+ # column_mappings
26
+ # This method attempts to automatically map headings in the CSV files
27
+ # with fields in the product and variant models.
28
+ # Rows[0] is an array of headings for columns - SKU, Master Price, etc.)
29
+ # @return a hash of symbol heading => column index pairs
30
+ def column_mappings
31
+ mappings
32
+ end
33
+
34
+ # variant_option_field?
35
+ # Class method that check if a field is a variant option field
36
+ # @return true or false
37
+ def variant_option_field?(field)
38
+ variant_option_fields.include?(field.to_s)
39
+ end
40
+
41
+ # property_field?
42
+ # Class method that check if a field is a product property field
43
+ # @return true or false
44
+ def property_field?(field)
45
+ property_fields.include?(field.to_s)
46
+ end
47
+
48
+ # image_field?
49
+ # Class method that check if a field is an image field
50
+ # @return true or false
51
+ def image_field?(field)
52
+ image_fields.include?(field.to_s)
53
+ end
54
+
55
+ # variant_image_field?
56
+ # Class method that check if a field is a variant image field
57
+ # @return true or false
58
+ def variant_image_field?(field)
59
+ variant_image_fields.include?(field.to_s)
60
+ end
61
+
62
+ # data_rows
63
+ # This method fetch the product rows.
64
+ # @return a array of columns with product information
65
+ def data_rows
66
+ rows[1..-1]
67
+ end
68
+
69
+ # products_count
70
+ # This method count the product rows.
71
+ # @return a integer
72
+ def products_count
73
+ data_rows.count
74
+ end
75
+
76
+ protected
77
+
78
+ def extract_column_mappings
79
+ rows[0].each_with_index do |heading, index|
80
+ break if heading.blank?
81
+ field_name = heading.downcase.gsub(/\A\s*/, '').chomp.gsub(/\s/, '_')
82
+ if field_name.include?('[opt]')
83
+ field_name.gsub!('[opt]', '')
84
+ variant_option_fields.push(field_name)
85
+ elsif field_name.include?('[prop]')
86
+ field_name.gsub!('[prop]', '')
87
+ property_fields.push(field_name)
88
+ elsif field_name.include?('image_product')
89
+ image_fields.push(field_name)
90
+ elsif field_name.include?('image_variant')
91
+ variant_image_fields.push(field_name)
92
+ end
93
+ mappings[field_name.to_sym] = index
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,82 @@
1
+ # This model is the master routine for uploading products
2
+ # Requires Paperclip and CSV to upload the CSV file and read it nicely.
3
+
4
+ # Original Author:: Josh McArthur
5
+ # License:: MIT
6
+ module Spree
7
+ class ProductImport < ActiveRecord::Base
8
+ ENCODINGS = %w[UTF-8 iso-8859-1].freeze
9
+
10
+ has_attached_file :data_file,
11
+ path: ':rails_root/tmp/product_data/data-files/:basename_:timestamp.:extension',
12
+ url: ':rails_root/tmp/product_data/data-files/:basename_:timestamp.:extension'
13
+
14
+ belongs_to :user, class_name: 'Spree::User', foreign_key: 'created_by', inverse_of: :product_imports
15
+
16
+ validates_attachment_presence :data_file
17
+ # Content type of csv vary in different browsers.
18
+ validates_attachment :data_file, presence: true, content_type: { content_type: ['text/csv', 'text/plain', 'text/comma-separated-values', 'application/octet-stream', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'] }
19
+
20
+ after_destroy :destroy_products
21
+
22
+ serialize :product_ids, Array
23
+ cattr_accessor :settings
24
+
25
+ state_machine initial: :created do
26
+ event :start do
27
+ transition to: :started, from: :created
28
+ end
29
+ event :complete do
30
+ transition to: :completed, from: :started
31
+ end
32
+ event :failure do
33
+ transition to: :failed, from: %i[created started]
34
+ end
35
+
36
+ before_transition to: [:failed] do |import|
37
+ import.product_ids = []
38
+ import.failed_at = Time.current
39
+ import.completed_at = nil
40
+ end
41
+
42
+ before_transition to: [:completed] do |import|
43
+ import.failed_at = nil
44
+ import.completed_at = Time.current
45
+ end
46
+ end
47
+
48
+ def parse
49
+ @_parse ||= SolidusImportProducts::Parser.parse(:csv, data_file.url(:default, timestamp: false), { encoding_csv: encoding_csv, separator_char: separatorChar })
50
+ end
51
+
52
+ def products
53
+ Product.where(id: product_ids)
54
+ end
55
+
56
+ def add_product(product)
57
+ product_ids << product.id unless product?(product)
58
+ end
59
+
60
+ def product?(product)
61
+ product.id && product_ids.include?(product.id)
62
+ end
63
+
64
+ def products_count
65
+ parse.product_count
66
+ end
67
+
68
+ def destroy_products
69
+ products.destroy_all
70
+ end
71
+
72
+ def state_datetime
73
+ if failed?
74
+ failed_at
75
+ elsif completed?
76
+ completed_at
77
+ else
78
+ updated_at
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,6 @@
1
+ Deface::Override.new(
2
+ virtual_path: 'spree/admin/shared/_settings_sub_menu',
3
+ name: 'product_import_admin_sidebar_menu',
4
+ insert_bottom: "[data-hook='admin_settings_sub_tabs']",
5
+ partial: 'spree/admin/shared/import_sidebar_menu'
6
+ )
@@ -0,0 +1,109 @@
1
+ module SolidusImportProducts
2
+ # CreateVariant
3
+ # This method assumes that some form of checking has already been done to
4
+ # make sure that we do actually want to create a variant.
5
+ # It performs a similar task to a product, but it also must pick up on
6
+ # size/color options
7
+ class CreateVariant
8
+ attr_accessor :product, :variant, :product_information, :logger
9
+
10
+ include SolidusImportProducts::ImportHelper
11
+
12
+ def self.call(options = {})
13
+ new.call(options)
14
+ end
15
+
16
+ def call(args = { product: nil, product_information: nil })
17
+ self.logger = SolidusImportProducts::Logger.instance
18
+ self.product_information = args[:product_information]
19
+ self.product = args[:product]
20
+ return if product_information.nil?
21
+
22
+ load_or_initialize_variant
23
+
24
+ product_information.each do |field, value|
25
+ if field == :variant_options
26
+ value.each { |variant_field, variant_value| options(variant_field, variant_value) }
27
+ elsif field == :attributes
28
+ value.each { |attr_field, attr_value| variant.send("#{attr_field}=", attr_value) if variant.respond_to?("#{attr_field}=") }
29
+ end
30
+ end
31
+
32
+ begin
33
+ variant.save
34
+
35
+ product_information[:variant_images].each do |filename|
36
+ find_and_attach_image_to(variant, filename)
37
+ end
38
+
39
+ stock_items
40
+ logger.log("Variant of SKU #{variant.sku} successfully imported.\n", :debug)
41
+ rescue StandardError => e
42
+ message = "A variant could not be imported - here is the information we have:\n"
43
+ message += "#{product_information}, #{variant.errors.full_messages.join(', ')}\n"
44
+ message += e.message.to_s
45
+ logger.log(message, :error)
46
+ raise SolidusImportProducts::Exception::VariantError, message
47
+ end
48
+ variant
49
+ end
50
+
51
+ private
52
+
53
+ def load_or_initialize_variant
54
+ self.variant = Spree::Variant.find_by(sku: product_information[:attributes][:sku])
55
+
56
+ if variant
57
+ if variant.product != product
58
+ raise SolidusImportProducts::Exception::SkuError,
59
+ "SKU #{product_information[:attributes][:sku]} should belongs to #{product.inspect} but was #{variant.product.inspect}"
60
+ end
61
+ product_information[:attributes].delete(:id)
62
+ else
63
+ self.variant = product.variants.new(sku: product_information[:attributes][:sku], id: product_information[:attributes][:id])
64
+ end
65
+ end
66
+
67
+ def options(field, value)
68
+ return unless value
69
+
70
+ option_type = get_or_create_option_type(field)
71
+ option_value = get_or_create_option_value(option_type, value)
72
+
73
+ product.option_types << option_type unless product.option_types.include?(option_type)
74
+
75
+ variant.option_values << option_value unless variant.option_values.include?(option_value)
76
+ end
77
+
78
+ def get_or_create_option_type(field)
79
+ Spree::OptionType.where('name = :field or presentation = :field', field: field.to_s).first ||
80
+ Spree::OptionType.create(name: field, presentation: field)
81
+ end
82
+
83
+ def get_or_create_option_value(option_type, value)
84
+ option_type.option_values.where('name = :value or presentation = :value', value: value).first ||
85
+ option_type.option_values.create(presentation: value, name: value)
86
+ end
87
+
88
+ def attach_image
89
+ Spree::ProductImport.settings[:image_fields_variants].each do |field|
90
+
91
+ end
92
+ end
93
+
94
+ def stock_items
95
+ source_location = Spree::StockLocation.find_by(default: true)
96
+ unless source_location
97
+ logger.log('Seems that there are no SourceLocation set right?, so stock will not set.', :warn) if product_information[:attributes][:stock] || product_information[:attributes][:backorderable]
98
+ return
99
+ end
100
+ logger.log("SourceLocation: #{source_location.inspect}", :debug)
101
+
102
+ stock_item = variant.stock_items.where(stock_location_id: source_location.id).first_or_initialize
103
+
104
+ stock_item.send('backorderable=', product_information[:attributes][:backorderable]) if product_information[:attributes].key?(:backorderable) && stock_item.respond_to?('backorderable=')
105
+
106
+ stock_item.set_count_on_hand(product_information[:attributes][:stock]) if product_information[:attributes][:stock]
107
+ end
108
+ end
109
+ end