import_products 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 2971bbecf8f07183d6d498c364e5b5adee68beb9
4
+ data.tar.gz: 6736b0073c7bfcfeead6b746ad905d6f393f12e8
5
+ SHA512:
6
+ metadata.gz: 3ac5dcbffc1b9ad1d1d1dbfb1f4cedf9e0be9e75e5d65b12a12e855068c9bf5fc70e78594876ddcd9947b9b97543eaea0c7b597e93b47820daed9eefc53f5d7b
7
+ data.tar.gz: bfc16a001c3232d8fbd2fc710551cc1443dc97f3964cd5cb8c82e4526625c1207542d26017c93968991d8fc70c95a06c58fe640ef0e99b6ec8fbdfc58ada97d8
data/LICENSE ADDED
@@ -0,0 +1,23 @@
1
+ Redistribution and use in source and binary forms, with or without modification,
2
+ are permitted provided that the following conditions are met:
3
+
4
+ * Redistributions of source code must retain the above copyright notice,
5
+ this list of conditions and the following disclaimer.
6
+ * Redistributions in binary form must reproduce the above copyright notice,
7
+ this list of conditions and the following disclaimer in the documentation
8
+ and/or other materials provided with the distribution.
9
+ * Neither the name of the Rails Dog LLC nor the names of its
10
+ contributors may be used to endorse or promote products derived from this
11
+ software without specific prior written permission.
12
+
13
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
14
+ "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
15
+ LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
16
+ A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
17
+ CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
18
+ EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
19
+ PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
20
+ PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
21
+ LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
22
+ NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
23
+ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,83 @@
1
+ Spree Import Products
2
+ ==============
3
+
4
+ This extension adds product import functionality to Spree, with a bunch of features that give it similar functionality to Shopify's importer.
5
+
6
+ It's been built to be as simple as possible while still doing it's job, and almost the entire workflow of the script beyond creating products from a CSV file is configurable.
7
+
8
+ This extension adds a tab to the administration area of Spree, allowing a logged-in user to select and upload a CSV file 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
+ * A reasonably opinionated product import model should take the heavy lifting out of batch-importing products.
15
+ * Using things like `class_eval`, it can be extended or changed to do more, less, or something differently.
16
+ * Using delayed_job, the import is no longer processed when the user uploads the CSV file (**Note**: this requires the proper installation and configuration of delayed job).
17
+ * Columns are mapped dynamically by default. This means that if you have a SKU column in your CSV file, it will be automatically set as the `sku` attribute of the Product model.
18
+ * Multiple taxonomies are supported (By default, the importer looks for Brand and Taxonomy). Multiple taxonomy nesting is also supported. (See the 'Taxonomies' area below)
19
+ * Multiple images are supported (By default, the importer looks for Image Main, Image 2, Image 3 and Image 4). Images can be loaded either from disk, or from a publicly-accessible URL.
20
+ * Automatically creating variants is supported out-of-the-box - the importer compares the imported data with already-created products (By default, on the Product's `permalink` attribute), and creates a variant of that product if it exists already. Custom fields are also supported if dynamic column mapping is enabled - simply having 'Color', 'Size', 'Material' columns in your CSV file for example, will automatically set the relevant custom fields on the variant to the values from the CSV.
21
+ * It now uses the Ruby 1.9.2 standard CSV library (a.k.a FasterCSV).
22
+
23
+
24
+ DELAYED JOB
25
+ ==============
26
+ This gem will require (or will install for you), [delayed_job](https://www.github.com/tobi/delayed_job).
27
+ Once the gem has installed and you have run migrations, you should also run `rails generator delayed_job` to create the tables that delayed_job requires.
28
+
29
+ Delayed Job also requires that you run 'workers' in the background to pop jobs off the queue and process them.
30
+ This setup may seem like extra work, but believe me, it pays off - with this method, users get an immediate confirmation that their import is on it's way, with a confirmation later on with full details - this is much better than the previous method where the actual processing was completed during the request, with no feedback reaching the user until after the import had finished.
31
+
32
+ Run `rake jobs:work` to start Delayed Job, and `rake jobs:clear` to clear all queued jobs. Also see delayed_job's Github page for info on Capistrano support.
33
+
34
+ For more information on Delayed Job, and for help getting a worker running, see the [Github Project Page](https://www.github.com/collectiveidea/delayed_job)
35
+
36
+ TAXONOMIES
37
+ ==========
38
+
39
+ The columns of the CSV that contain taxonomies is configurable. Each of these columns can contain a number of formats that represent different hierarchies of taxonomies.
40
+
41
+ Examples
42
+ --------
43
+ * Basic taxonomy association for Category: `Furniture`
44
+ * Multiple taxonomy association for Category: `Furniture & Clearance`
45
+ * Hierarchy taxonomy association for Category: `Furniture > Dining Room > Tables`
46
+ * Multiple hierarchy taxonomy association for Category: `Furniture > Dining Room > Tables & Clearance > Hot this week`
47
+
48
+ CONFIGURATION
49
+ =============
50
+
51
+ All the configuration for this extension is inside the initializer generated when you run `rake import_products:install`. It's basically a big hash with manual column mappings (If you don't want to use dynamic column mapping), and a bunch of settings that control the workflow of the extension. Take a look at the initializer to see more details on each field.
52
+
53
+ In most cases, it's unlikely you will need to change defaults, but it's there is you need it.
54
+
55
+
56
+ TESTING
57
+ =======
58
+
59
+ ```ruby
60
+ rake test_app
61
+ cd spec/dummy
62
+ rake db:create
63
+ rake db:migrate db:test:prepare
64
+ rails generate delayed_job:active_record
65
+ rails generate import_products:install
66
+ ```
67
+
68
+ INSTALLATION
69
+ ==============
70
+ 1. Add the gem to your Gemfile, and run bundle install.
71
+ `gem 'import_products', :git => 'git://github.com/joshmcarthur/spree-import-products.git'` then `bundle install`
72
+
73
+ 2. rails generate import_products:install
74
+
75
+ 3. Configure the extension to suit your application by changing config variables in `config/initializers/import_product_settings.rb`
76
+
77
+ 4. Run application!
78
+
79
+ ATTRIBUTION
80
+ ==============
81
+ The product import script was based on a simple import script written by Brian Quinn [here](https://gist.github.com/31710). I've extended it quite a bit and tweaked it to fit my needs.
82
+
83
+ Copyright (c) 2010 Josh McArthur, released under the MIT License
@@ -0,0 +1,35 @@
1
+ module Spree
2
+ module Admin
3
+ class ProductImportsController < BaseController
4
+
5
+ def index
6
+ @product_import = Spree::ProductImport.new
7
+ end
8
+
9
+ def show
10
+ @product_import = Spree::ProductImport.find(params[:id])
11
+ @products = @product_import.products
12
+ end
13
+
14
+ def create
15
+ @product_import = Spree::ProductImport.create(product_import_params)
16
+ ImportProductsJob.perform_later(@product_import)
17
+ flash[:notice] = t('product_import_processing')
18
+ redirect_to admin_product_imports_path
19
+ end
20
+
21
+ def destroy
22
+ @product_import = Spree::ProductImport.find(params[:id])
23
+ if @product_import.destroy
24
+ flash[:notice] = t('delete_product_import_successful')
25
+ end
26
+ redirect_to admin_product_imports_path
27
+ end
28
+
29
+ private
30
+ def product_import_params
31
+ params.require(:product_import).permit!
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,22 @@
1
+ class ImportProductsJob < ActiveJob::Base
2
+ queue_as :default
3
+
4
+ after_perform :notify_admin
5
+
6
+ rescue_from(StandardError) do |exception|
7
+ Spree::UserMailer.product_import_results(Spree::User.admin.first, exception.message).deliver_later
8
+ end
9
+
10
+ def perform(products)
11
+ products.import_data!(Spree::ProductImport.settings[:transaction])
12
+ end
13
+
14
+ def notify_admin
15
+ # Spree::UserMailer.product_import_results(Spree::User.admin.first).deliver_later
16
+ puts "*********************************************************"
17
+ puts "*********************************************************"
18
+ puts "==================== Import Complete ===================="
19
+ puts "*********************************************************"
20
+ puts "*********************************************************"
21
+ end
22
+ end
@@ -0,0 +1,472 @@
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 ProductError < StandardError; end;
8
+ class ImportError < StandardError; end;
9
+ class SkuError < StandardError; end;
10
+
11
+ class ProductImport < ActiveRecord::Base
12
+ #attr_accessible :data_file, :data_file_file_name, :data_file_content_type, :data_file_file_size, :data_file_updated_at, :product_ids, :state, :failed_at, :completed_at
13
+ has_attached_file :data_file, :path => ":rails_root/lib/etc/product_data/data-files/:basename.:extension"
14
+ validates_attachment_presence :data_file
15
+ validates_attachment :data_file, :presence => true, content_type: { content_type: "text/csv" }
16
+
17
+ # after_destroy :destroy_products
18
+
19
+ serialize :product_ids, Array
20
+ cattr_accessor :settings
21
+
22
+ def products
23
+ Product.where :id => product_ids
24
+ end
25
+
26
+ require 'csv'
27
+ require 'pp'
28
+ require 'open-uri'
29
+
30
+ # def destroy_products
31
+ # products.destroy_all
32
+ # end
33
+
34
+ state_machine :initial => :created do
35
+
36
+ event :start do
37
+ transition :to => :started, :from => :created
38
+ end
39
+ event :complete do
40
+ transition :to => :completed, :from => :started
41
+ end
42
+ event :failure do
43
+ transition :to => :failed, :from => :started
44
+ end
45
+
46
+ before_transition :to => [:failed] do |import|
47
+ import.product_ids = []
48
+ import.failed_at = Time.now
49
+ import.completed_at = nil
50
+ end
51
+
52
+ before_transition :to => [:completed] do |import|
53
+ import.failed_at = nil
54
+ import.completed_at = Time.now
55
+ end
56
+ end
57
+
58
+ def state_datetime
59
+ if failed?
60
+ failed_at
61
+ elsif completed?
62
+ completed_at
63
+ else
64
+ Time.now
65
+ end
66
+ end
67
+
68
+ ## Data Importing:
69
+ # List Price maps to Master Price, Current MAP to Cost Price, Net 30 Cost unused
70
+ # Width, height, Depth all map directly to object
71
+ # Image main is created independtly, then each other image also created and associated with the product
72
+ # Meta keywords and description are created on the product model
73
+
74
+ def import_data!(_transaction=true)
75
+ start
76
+ if _transaction
77
+ transaction do
78
+ _import_data
79
+ end
80
+ else
81
+ _import_data
82
+ end
83
+ end
84
+
85
+ def _import_data
86
+ begin
87
+ @products_before_import = Spree::Product.all
88
+ @skus_of_products_before_import = @products_before_import.map(&:sku)
89
+
90
+ #rows = CSV.read(self.data_file.path, :encoding => 'windows-1251:utf-8')
91
+ rows = CSV.read(self.data_file.path, "r:ISO-8859-1")
92
+
93
+ if ProductImport.settings[:first_row_is_headings]
94
+ col = get_column_mappings(rows[0])
95
+ else
96
+ col = ProductImport.settings[:column_mappings]
97
+ end
98
+
99
+ rows[ProductImport.settings[:rows_to_skip]..-1].each do |row|
100
+ product_information = {}
101
+
102
+ #Automatically map 'mapped' fields to a collection of product information.
103
+ #NOTE: This code will deal better with the auto-mapping function - i.e. if there
104
+ #are named columns in the spreadsheet that correspond to product
105
+ # and variant field names.
106
+
107
+ col.each do |key, value|
108
+ #Trim whitespace off the beginning and end of row fields
109
+ row[value].try :strip!
110
+ product_information[key] = row[value]
111
+ end
112
+
113
+ #Manually set available_on if it is not already set
114
+ product_information[:available_on] = Date.today - 1.day if product_information[:available_on].nil?
115
+ #product_information[:price] = 0
116
+
117
+ sc = Spree::ShippingCategory.first
118
+ product_information[:shipping_category_id] = sc.id unless sc.nil?
119
+
120
+ log("#{pp product_information}")
121
+
122
+ variant_comparator_field = ProductImport.settings[:variant_comparator_field].try :to_sym
123
+ variant_comparator_column = col[variant_comparator_field]
124
+
125
+ if ProductImport.settings[:create_variants] and variant_comparator_column and
126
+ p = Product.where(variant_comparator_field => row[variant_comparator_column]).first
127
+
128
+ p.update_attribute(:deleted_at, nil) if p.deleted_at #Un-delete product if it is there
129
+ p.variants.each { |variant| variant.update_attribute(:deleted_at, nil) }
130
+ create_variant_for(p, :with => product_information)
131
+ else
132
+ next if @skus_of_products_before_import.include?(product_information[:sku])
133
+ next unless create_product_using(product_information)
134
+ end
135
+ end
136
+
137
+ if ProductImport.settings[:destroy_original_products]
138
+ @products_before_import.each { |p| p.destroy }
139
+ end
140
+
141
+ end
142
+
143
+ # Finished Importing!
144
+ complete
145
+ return [:notice, "Product data was successfully imported."]
146
+ end
147
+
148
+ private
149
+
150
+
151
+ # create_variant_for
152
+ # This method assumes that some form of checking has already been done to
153
+ # make sure that we do actually want to create a variant.
154
+ # It performs a similar task to a product, but it also must pick up on
155
+ # size/color options
156
+ def create_variant_for(product, options = {:with => {}})
157
+ return if options[:with].nil?
158
+
159
+ # Just update variant if exists
160
+ variant = Variant.find_by_sku(options[:with][:sku])
161
+ raise SkuError, "SKU #{variant.sku} should belongs to #{product.inspect} but was #{variant.product.inspect}" if variant && variant.product != product
162
+ if !variant
163
+ variant = product.variants.new
164
+ variant.id = options[:with][:id]
165
+ else
166
+ options[:with].delete(:id)
167
+ end
168
+
169
+ field = ProductImport.settings[:variant_comparator_field]
170
+ log "VARIANT:: #{variant.inspect} /// #{options.inspect } /// #{options[:with][field]} /// #{field}"
171
+
172
+ #Remap the options - oddly enough, Spree's product model has master_price and cost_price, while
173
+ #variant has price and cost_price.
174
+
175
+ options[:with][:price] = options[:with].delete(:price)
176
+
177
+ #First, set the primitive fields on the object (prices, etc.)
178
+
179
+ options[:with].each do |field, value|
180
+ variant.send("#{field}=", value) if variant.respond_to?("#{field}=")
181
+ applicable_option_type = OptionType.where("lower(presentation) = ? OR lower(name) = ?",field.to_s, field.to_s).first
182
+ if applicable_option_type.is_a?(OptionType)
183
+ product.option_types << applicable_option_type unless product.option_types.include?(applicable_option_type)
184
+ opt_value = applicable_option_type.option_values.where(["presentation = ? OR name = ?", value, value]).first
185
+ opt_value = applicable_option_type.option_values.create(:presentation => value, :name => value) unless opt_value
186
+ variant.option_values << opt_value unless variant.option_values.include?(opt_value)
187
+ end
188
+ end
189
+
190
+ log "VARIANT PRICE #{variant.inspect} /// #{variant.price}"
191
+
192
+ if variant.valid?
193
+ variant.save
194
+
195
+ #Associate our new variant with any new taxonomies
196
+ ProductImport.settings[:taxonomy_fields].each do |field|
197
+ associate_product_with_taxon(variant.product, field.to_s, options[:with][field.to_sym])
198
+ end
199
+
200
+ #Associate our new variant with any stock item
201
+ source_location = Spree::StockLocation.find_by(default: true)
202
+ stock_item = variant.stock_items.where(stock_location_id: source_location.id).first
203
+
204
+ if options[:with][:on_hand].nil?
205
+ stock_item.set_count_on_hand(0)
206
+ else
207
+ stock_item.set_count_on_hand(options[:with][:on_hand])
208
+ end
209
+
210
+ #Finally, attach any images that have been specified
211
+ ProductImport.settings[:image_fields].each do |field|
212
+ find_and_attach_image_to(variant, options[:with][field.to_sym])
213
+ end
214
+
215
+ #Log a success message
216
+ log("Variant of SKU #{variant.sku} successfully imported.\n")
217
+ else
218
+ log("A variant could not be imported - here is the information we have:\n" +
219
+ "#{pp options[:with]}, #{variant.errors.full_messages.join(', ')}")
220
+ return false
221
+ end
222
+ end
223
+
224
+
225
+ # create_product_using
226
+ # This method performs the meaty bit of the import - taking the parameters for the
227
+ # product we have gathered, and creating the product and related objects.
228
+ # It also logs throughout the method to try and give some indication of process.
229
+ def create_product_using(params_hash)
230
+
231
+ product = Product.new
232
+ properties_hash = Hash.new
233
+
234
+ # Array of special fields. Prevent adding them to properties.
235
+ special_fields = ProductImport.settings.values_at(
236
+ :image_fields,
237
+ :taxonomy_fields,
238
+ :store_field,
239
+ :variant_comparator_field
240
+ ).flatten.map(&:to_s)
241
+
242
+ #The product is inclined to complain if we just dump all params
243
+ # into the product (including images and taxonomies).
244
+ # What this does is only assigns values to products if the product accepts that field.
245
+ params_hash.each do |field, value|
246
+ if product.respond_to?("#{field}=")
247
+ product.send("#{field}=", value)
248
+ elsif not special_fields.include?(field.to_s) and property = Property.where("lower(name) = ?", field).first
249
+ properties_hash[property] = value
250
+ end
251
+ end
252
+
253
+ after_product_built(product, params_hash)
254
+
255
+ #We can't continue without a valid product here
256
+ unless product.valid?
257
+ log(msg = "A product could not be imported - here is the information we have:\n" +
258
+ "#{pp params_hash}, #{product.errors.full_messages.join(', ')}")
259
+ raise ProductError, msg
260
+ end
261
+
262
+ #Just log which product we're processing
263
+ log(product.name)
264
+
265
+ #This should be caught by code in the main import code that checks whether to create
266
+ #variants or not. Since that check can be turned off, however, we should double check.
267
+ p = Spree::Variant.find_by_sku(product.sku)
268
+ if @skus_of_products_before_import.include? product.sku and p.deleted_at.nil?
269
+ log("#{product.name} is already in the system and active.\n")
270
+ else
271
+ if !p.nil? && !p.deleted_at.nil?
272
+ p.destroy
273
+ log("#{product.name} was removed from the system and will be replaced.\n")
274
+ end
275
+
276
+ #Save the object before creating asssociated objects
277
+ product.save and product_ids << product.id
278
+ log("Saved object before creating associated objects for: #{product.name}")
279
+
280
+ #Associate properties with product
281
+ properties_hash.each do |property, value|
282
+ product_property = Spree::ProductProperty.where(:product_id => product.id, :property_id => property.id).first_or_initialize
283
+ product_property.value = value
284
+ product_property.save!
285
+ end
286
+
287
+ #Associate our new product with any taxonomies that we need to worry about
288
+ ProductImport.settings[:taxonomy_fields].each do |field|
289
+ associate_product_with_taxon(product, field.to_s, params_hash[field.to_sym])
290
+ end
291
+
292
+
293
+ #Finally, attach any images that have been specified
294
+ ProductImport.settings[:image_fields].each do |field|
295
+ find_and_attach_image_to(product, params_hash[field.to_sym])
296
+ end
297
+
298
+ if ProductImport.settings[:multi_domain_importing] && product.respond_to?(:stores)
299
+ begin
300
+ store = Store.find(
301
+ :first,
302
+ :conditions => ["id = ? OR code = ?",
303
+ params_hash[ProductImport.settings[:store_field]],
304
+ params_hash[ProductImport.settings[:store_field]]
305
+ ]
306
+ )
307
+
308
+ product.stores << store
309
+ rescue
310
+ log("#{product.name} could not be associated with a store. Ensure that Spree's multi_domain extension is installed and that fields are mapped to the CSV correctly.")
311
+ end
312
+ end
313
+
314
+ #Stock item
315
+ source_location = Spree::StockLocation.find_by(default: true)
316
+ stock_item = product.stock_items.where(stock_location_id: source_location.id).first
317
+
318
+ if params_hash[:on_hand].nil?
319
+ stock_item.set_count_on_hand(0)
320
+ else
321
+ stock_item.set_count_on_hand(params_hash[:on_hand])
322
+ end
323
+
324
+ log("#{product.name} successfully imported.\n")
325
+ end
326
+ return true
327
+ end
328
+
329
+ # get_column_mappings
330
+ # This method attempts to automatically map headings in the CSV files
331
+ # with fields in the product and variant models.
332
+ # If the headings of columns are going to be called something other than this,
333
+ # or if the files will not have headings, then the manual initializer
334
+ # mapping of columns must be used.
335
+ # Row is an array of headings for columns - SKU, Master Price, etc.)
336
+ # @return a hash of symbol heading => column index pairs
337
+ def get_column_mappings(row)
338
+ mappings = {}
339
+ row.each_with_index do |heading, index|
340
+ # Stop collecting headings, if heading is empty
341
+ if not heading.blank?
342
+ mappings[heading.downcase.gsub(/\A\s*/, '').chomp.gsub(/\s/, '_').to_sym] = index
343
+ else
344
+ break
345
+ end
346
+ end
347
+ mappings
348
+ end
349
+
350
+
351
+ ### MISC HELPERS ####
352
+
353
+ # Log a message to a file - logs in standard Rails format to logfile set up in the import_products initializer
354
+ # and console.
355
+ # Message is string, severity symbol - either :info, :warn or :error
356
+
357
+ def log(message, severity = :info)
358
+ @rake_log ||= ActiveSupport::Logger.new(ProductImport.settings[:log_to])
359
+ message = "[#{Time.now.to_s(:db)}] [#{severity.to_s.capitalize}] #{message}\n"
360
+ @rake_log.send severity, message
361
+ puts message
362
+ end
363
+
364
+
365
+ ### IMAGE HELPERS ###
366
+
367
+ # find_and_attach_image_to
368
+ # This method attaches images to products. The images may come
369
+ # from a local source (i.e. on disk), or they may be online (HTTP/HTTPS).
370
+ def find_and_attach_image_to(product_or_variant, filename)
371
+ return if filename.blank?
372
+
373
+ #The image can be fetched from an HTTP or local source - either method returns a Tempfile
374
+ file = filename =~ /\Ahttp[s]*:\/\// ? fetch_remote_image(filename) : fetch_local_image(filename)
375
+
376
+ #An image has an attachment (the image file) and some object which 'views' it
377
+ product_image = Spree::Image.new({:attachment => file,
378
+ :viewable_id => product_or_variant.id,
379
+ :viewable_type => "Spree::Variant",
380
+ :position => product_or_variant.images.length+1
381
+ })
382
+
383
+ log("#{product_image.viewable_id} : #{product_image.viewable_type} : #{product_image.position}")
384
+
385
+ product_or_variant.images << product_image if product_image.save
386
+ end
387
+
388
+ # This method is used when we have a set location on disk for
389
+ # images, and the file is accessible to the script.
390
+ # It is basically just a wrapper around basic File IO methods.
391
+ def fetch_local_image(filename)
392
+ filename = ProductImport.settings[:product_image_path] + filename
393
+ unless File.exists?(filename) && File.readable?(filename)
394
+ log("Image #{filename} was not found on the server, so this image was not imported.", :warn)
395
+ return nil
396
+ else
397
+ return File.open(filename, 'rb')
398
+ end
399
+ end
400
+
401
+
402
+ #This method can be used when the filename matches the format of a URL.
403
+ # It uses open-uri to fetch the file, returning a Tempfile object if it
404
+ # is successful.
405
+ # If it fails, it in the first instance logs the HTTP error (404, 500 etc)
406
+ # If it fails altogether, it logs it and exits the method.
407
+ def fetch_remote_image(filename)
408
+ begin
409
+ io = open(URI.parse(filename))
410
+ def io.original_filename; base_uri.path.split('/').last; end
411
+ return io
412
+ rescue OpenURI::HTTPError => error
413
+ log("Image #{filename} retrival returned #{error.message}, so this image was not imported")
414
+ rescue => error
415
+ log("Image #{filename} could not be downloaded, so was not imported. #{error.message}")
416
+ end
417
+ end
418
+
419
+ ### TAXON HELPERS ###
420
+
421
+ # associate_product_with_taxon
422
+ # This method accepts three formats of taxon hierarchy strings which will
423
+ # associate the given products with taxons:
424
+ # 1. A string on it's own will will just find or create the taxon and
425
+ # add the product to it. e.g. taxonomy = "Category", taxon_hierarchy = "Tools" will
426
+ # add the product to the 'Tools' category.
427
+ # 2. A item > item > item structured string will read this like a tree - allowing
428
+ # a particular taxon to be picked out
429
+ # 3. An item > item & item > item will work as above, but will associate multiple
430
+ # taxons with that product. This form should also work with format 1.
431
+ def associate_product_with_taxon(product, taxonomy, taxon_hierarchy)
432
+ return if product.nil? || taxonomy.nil? || taxon_hierarchy.nil?
433
+
434
+ #Using find_or_create_by_name is more elegant, but our magical params code automatically downcases
435
+ # the taxonomy name, so unless we are using MySQL, this isn't going to work.
436
+ # taxonomy_name = taxonomy
437
+ # taxonomy = Taxonomy.find(:first, :conditions => ["lower(name) = ?", taxonomy])
438
+ # taxonomy = Taxonomy.create(:name => taxonomy_name.capitalize) if taxonomy.nil? && ProductImport.settings[:create_missing_taxonomies]
439
+
440
+ taxon_hierarchy.split(/\s*\|\s*/).each do |hierarchy|
441
+ hierarchy = hierarchy.split(/\s*>\s*/)
442
+ taxonomy = Spree::Taxonomy.where("lower(name) = ?", hierarchy.first.downcase).first
443
+ taxonomy = Taxonomy.create(:name => hierarchy.first.capitalize) if taxonomy.nil? && ProductImport.settings[:create_missing_taxonomies]
444
+ last_taxon = taxonomy.root
445
+ product.taxons << last_taxon unless product.taxons.include?(last_taxon)
446
+
447
+ hierarchy.shift
448
+ hierarchy.each do |taxon|
449
+ taxon = taxon.titleize
450
+ #last_taxon = last_taxon.children.find_or_create_by_name_and_taxonomy_id(taxon, taxonomy.id)
451
+ last_taxon = last_taxon.children.find_or_create_by(name: taxon, taxonomy_id: taxonomy.id)
452
+ product.taxons << last_taxon unless product.taxons.include?(last_taxon)
453
+ end
454
+
455
+ end
456
+ end
457
+ ### END TAXON HELPERS ###
458
+
459
+ # May be implemented via decorator if useful:
460
+ #
461
+ # Spree::ProductImport.class_eval do
462
+ #
463
+ # private
464
+ #
465
+ # def after_product_built(product, params_hash)
466
+ # # so something with the product
467
+ # end
468
+ # end
469
+ def after_product_built(product, params_hash)
470
+ end
471
+ end
472
+ end
@@ -0,0 +1,6 @@
1
+ Deface::Override.new(
2
+ virtual_path: 'spree/layouts/admin',
3
+ name: 'product_import_admin_sidebar_menu',
4
+ insert_bottom: '#main-sidebar',
5
+ partial: 'spree/admin/shared/import_sidebar_menu'
6
+ )
@@ -0,0 +1,55 @@
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') %>
11
+ <%= f.file_field :data_file %>
12
+ <%= f.error_message_on :data_file %>
13
+ <% end %>
14
+ <div data-hook="buttons" class="form-actions">
15
+ <%= button Spree.t('actions.create'), 'ok', 'submit', {class: 'btn-success'} %>
16
+ </div>
17
+ </fieldset>
18
+ <% end %>
19
+ </div>
20
+ </div>
21
+
22
+ <table class="table">
23
+ <colgroup>
24
+ <col style="width: 15%">
25
+ <col style="width: 40%">
26
+ <col style="width: 10%">
27
+ <col style="width: 15%">
28
+ <col style="width: 10%">
29
+ <col style="width: 5%">
30
+ </colgroup>
31
+ <thead>
32
+ <tr>
33
+ <th>Date creation</th>
34
+ <th>CSV Name</th>
35
+ <th>Status</th>
36
+ <th>Date Status</th>
37
+ <th>Imported</th>
38
+ <th class="actions">Actions</th>
39
+ </tr>
40
+ </thead>
41
+ <tbody>
42
+ <% Spree::ProductImport.order("created_at DESC").all.each do |import| %>
43
+ <tr class="<%= cycle('odd', 'even') %>" id="<%= dom_id import %>">
44
+ <td><%= time_ago_in_words import.created_at -%> ago</td>
45
+ <td><%= link_to import.data_file_file_name, admin_product_import_path(import) -%></td>
46
+ <td><span class="label label-<%= import.state.downcase %>"><%= t(import.state, :scope => "product_import.state") -%></span></td>
47
+ <td><%= time_ago_in_words import.state_datetime -%> ago</td>
48
+ <td><%= import.product_ids.size -%></td>
49
+ <td class="actions text-center">
50
+ <%= link_to_delete import, :url => admin_product_import_path(import), :no_text => true -%>
51
+ </td>
52
+ </tr>
53
+ <% end %>
54
+ </tbody>
55
+ </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,5 @@
1
+ <% if can? :admin, Spree::ProductImport %>
2
+ <ul class="nav nav-sidebar">
3
+ <%= tab Spree::ProductImport, url: spree.admin_product_imports_url, icon: 'import', label: plural_resource_name(Spree::ProductImport) %>
4
+ </ul>
5
+ <% end %>
@@ -0,0 +1,21 @@
1
+ Hello,
2
+
3
+ <% if @error_message %>
4
+ --
5
+ <%= @error_message %>
6
+ --
7
+ Something went wrong during the product import.
8
+ Common causes for this include:
9
+ - Badly formatted CSV
10
+ - Special characters within the file
11
+ - Incorrect data
12
+ - Application or database error
13
+
14
+ Please check your file and try again.
15
+ To help diagnose the problem, we have attached the log file for the imports.
16
+ <% else %>
17
+ This is a quick email to let you know that the product import you submitted has been completed.
18
+ You can now see the products you added visible in your store.
19
+ <% end %>
20
+
21
+
@@ -0,0 +1,30 @@
1
+ module ImportProducts
2
+ module Generators
3
+ class InstallGenerator < Rails::Generators::Base
4
+ def self.source_paths
5
+ paths = self.superclass.source_paths
6
+ paths << File.expand_path('../templates', "../../#{__FILE__}")
7
+ paths << File.expand_path('../templates', "../#{__FILE__}")
8
+ paths << File.expand_path('../templates', __FILE__)
9
+ paths.flatten
10
+ end
11
+
12
+ def add_migrations
13
+ run 'bundle exec rake railties:install:migrations FROM=import_products'
14
+ end
15
+
16
+ def add_files
17
+ template 'config/initializers/import_product_settings.rb', 'config/initializers/import_product_settings.rb'
18
+ end
19
+
20
+ def run_migrations
21
+ res = ask "Would you like to run the migrations now? [Y/n]"
22
+ if res == "" || res.downcase == "y"
23
+ run 'bundle exec rake db:migrate'
24
+ else
25
+ puts "Skiping rake db:migrate, don't forget to run it!"
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,36 @@
1
+ # This file is the thing you have to config to match your application
2
+
3
+ # This file is the thing you have to config to match your application
4
+
5
+ Spree::ProductImport.settings = {
6
+ :column_mappings => { #Change these for manual mapping of product fields to the CSV file
7
+ :sku => 0,
8
+ :name => 1,
9
+ :master_price => 2,
10
+ :cost_price => 3,
11
+ :weight => 4,
12
+ :height => 5,
13
+ :width => 6,
14
+ :depth => 7,
15
+ :image_main => 8,
16
+ :image_2 => 9,
17
+ :image_3 => 10,
18
+ :image_4 => 11,
19
+ :description => 12,
20
+ :category => 13
21
+ },
22
+ :create_missing_taxonomies => true,
23
+ :taxonomy_fields => [:category, :brand], #Fields that should automatically be parsed for taxons to associate
24
+ :image_fields => [:image_main, :image_2, :image_3, :image_4], #Image fields that should be parsed for image locations
25
+ :product_image_path => "#{Rails.root}/lib/etc/product_data/product-images/", #The location of images on disk
26
+ :rows_to_skip => 1, #If your CSV file will have headers, this field changes how many rows the reader will skip
27
+ :log_to => File.join(Rails.root, '/log/', "import_products_#{Rails.env}.log"), #Where to log to
28
+ :destroy_original_products => false, #Delete the products originally in the database after the import?
29
+ :first_row_is_headings => true, #Reads column names from first row if set to true.
30
+ :create_variants => true, #Compares products and creates a variant if that product already exists.
31
+ :variant_comparator_field => :permalink, #Which product field to detect duplicates on
32
+ :multi_domain_importing => true, #If Spree's multi_domain extension is installed, associates products with store
33
+ :store_field => :store_code, #Which field of the column mappings contains either the store id or store code?
34
+ :transaction => true #import product in a sql transaction so we can rollback when an exception is raised
35
+ }
36
+
@@ -0,0 +1,3 @@
1
+ require 'spree_core'
2
+ require 'spree_auth_devise'
3
+ require 'import_products/engine'
@@ -0,0 +1,22 @@
1
+ module ImportProducts
2
+ class Engine < Rails::Engine
3
+ engine_name 'import_products'
4
+
5
+ config.autoload_paths += %W(#{config.root}/lib)
6
+
7
+ def self.activate
8
+ Dir.glob(File.join(File.dirname(__FILE__), "../app/**/*_decorator*.rb")) do |c|
9
+ Rails.env.production? ? require(c) : load(c)
10
+ end
11
+
12
+ Dir.glob(File.join(File.dirname(__FILE__), "../../app/overrides/*.rb")) do |c|
13
+ Rails.application.config.cache_classes ? require(c) : load(c)
14
+ end
15
+
16
+ Spree::UserMailer.send(:include, ImportProducts::UserMailerExt)
17
+
18
+ end
19
+
20
+ config.to_prepare &method(:activate).to_proc
21
+ end
22
+ end
@@ -0,0 +1,14 @@
1
+ module ImportProducts
2
+ module UserMailerExt
3
+ def self.included(base)
4
+ base.class_eval do
5
+ def product_import_results(user, error_message = nil)
6
+ @user = user
7
+ @error_message = error_message
8
+ attachments["import_products.log"] = File.read(Spree::ProductImport.settings[:log_to]) if @error_message.nil?
9
+ mail(:to => @user.email, :from => 'fonemstr@gmail.com', :subject => "Spree: Import Products #{error_message.nil? ? "Success" : "Failure"}")
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1 @@
1
+ # add custom rake tasks here
metadata ADDED
@@ -0,0 +1,200 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: import_products
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Josh McArthur
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2017-07-25 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: spree_core
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 1.3.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 1.3.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: spree_auth_devise
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: spree_sample
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: sqlite3
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: ffaker
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: 1.12.0
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: 1.12.0
83
+ - !ruby/object:Gem::Dependency
84
+ name: rspec-rails
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: capybara
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: launchy
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - '='
116
+ - !ruby/object:Gem::Version
117
+ version: 2.0.5
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - '='
123
+ - !ruby/object:Gem::Version
124
+ version: 2.0.5
125
+ - !ruby/object:Gem::Dependency
126
+ name: factory_girl
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ - !ruby/object:Gem::Dependency
140
+ name: ruby-debug19
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - ">="
144
+ - !ruby/object:Gem::Version
145
+ version: '0'
146
+ type: :development
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - ">="
151
+ - !ruby/object:Gem::Version
152
+ version: '0'
153
+ description:
154
+ email: josh@3months.com
155
+ executables: []
156
+ extensions: []
157
+ extra_rdoc_files: []
158
+ files:
159
+ - LICENSE
160
+ - README.md
161
+ - app/controllers/spree/admin/product_imports_controller.rb
162
+ - app/jobs/import_products_job.rb
163
+ - app/models/spree/product_import.rb
164
+ - app/overrides/add_import_to_admin_sidebar_menu.rb
165
+ - app/views/spree/admin/product_imports/index.html.erb
166
+ - app/views/spree/admin/product_imports/show.html.erb
167
+ - app/views/spree/admin/shared/_import_sidebar_menu.erb
168
+ - app/views/spree/user_mailer/product_import_results.text.erb
169
+ - lib/generators/import_products/install/install_generator.rb
170
+ - lib/generators/import_products/install/templates/config/initializers/import_product_settings.rb
171
+ - lib/import_products.rb
172
+ - lib/import_products/engine.rb
173
+ - lib/import_products/user_mailer_ext.rb
174
+ - lib/tasks/import_products.rake
175
+ homepage: http://www.3months.com
176
+ licenses: []
177
+ metadata: {}
178
+ post_install_message:
179
+ rdoc_options: []
180
+ require_paths:
181
+ - lib
182
+ required_ruby_version: !ruby/object:Gem::Requirement
183
+ requirements:
184
+ - - ">="
185
+ - !ruby/object:Gem::Version
186
+ version: 1.8.7
187
+ required_rubygems_version: !ruby/object:Gem::Requirement
188
+ requirements:
189
+ - - ">="
190
+ - !ruby/object:Gem::Version
191
+ version: '0'
192
+ requirements:
193
+ - none
194
+ rubyforge_project:
195
+ rubygems_version: 2.6.12
196
+ signing_key:
197
+ specification_version: 4
198
+ summary: spree_import_products ... imports products. From a CSV file via Spree's Admin
199
+ interface
200
+ test_files: []