Permalink
Browse files

Waved wand, created extension.

  • Loading branch information...
0 parents commit 6c17d99b964db9b1b9318ac52ae5437dd14ff31d Josh committed Dec 13, 2010
@@ -0,0 +1,9 @@
+\#*
+*~
+.#*
+.DS_Store
+.idea
+.project
+tmp
+nbproject
+*.swp
23 LICENSE
@@ -0,0 +1,23 @@
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+ * Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+ * Neither the name of the Rails Dog LLC nor the names of its
+ contributors may be used to endorse or promote products derived from this
+ software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,25 @@
+Spree Import Products
+==============
+
+I've used this combination of model/controller/script to add product import functionality to a couple of projects now.
+It's a fairly simple (but easy to extend), drop-in Spree extension that adds an interface to the Administration area
+that allows a user to select and upload a CSV file containing information on products.
+
+The script portion of this extension then reads the file, creating products with associated information, and
+finding, attaching and saving images and taxonomies to the product object.
+
+TODOs
+==============
+Adding some sort of support for running this under delayed_job is something that I think is probably reasonably
+necessary for a routine like this, but not something I've had time to look into.
+
+Apart from that, just testing really.
+
+INSTALLATION
+==============
+1) Add the gem to your Gemfile, and run bundle install.
+2) rake db:migrate
+3) Run application - you may want to check out lib/import_products.rb for settings you can configure, as well as the
+default column order.
+
+Copyright (c) 2010 Josh McArthur, released under the MIT License
@@ -0,0 +1,31 @@
+require File.expand_path('../../config/application', __FILE__)
+
+require 'rubygems'
+require 'rake'
+require 'rake/testtask'
+require 'rake/packagetask'
+require 'rake/gempackagetask'
+
+spec = eval(File.read('import_products.gemspec'))
+
+Rake::GemPackageTask.new(spec) do |p|
+ p.gem_spec = spec
+end
+
+desc "Release to gemcutter"
+task :release => :package do
+ require 'rake/gemcutter'
+ Rake::Gemcutter::Tasks.new(spec).define
+ Rake::Task['gem:push'].invoke
+end
+
+desc "Default Task"
+task :default => [ :spec ]
+
+require 'rspec/core/rake_task'
+RSpec::Core::RakeTask.new
+
+# require 'cucumber/rake/task'
+# Cucumber::Rake::Task.new do |t|
+# t.cucumber_opts = %w{--format pretty}
+# end
@@ -0,0 +1,23 @@
+class Admin::ProductImportController < Admin::BaseController
+
+ #Sorry for not using resource_controller railsdog - I wanted to, but then... I did it this way.
+ #Verbosity is nice?
+ #Feel free to refactor and submit a pull request.
+
+ def index
+ redirect_to :action => :new
+ end
+
+ def new
+ @product_import = ProductImport.new
+ end
+
+
+ def create
+ @product_import = ProductImport.create(params[:product_import])
+ #import_data returns an array with two elements - a symbol (notice or error), and a message
+ import_results = @product_import.import_data
+ flash[import_results[0]] = import_results[1]
+ render :new
+ end
+end
@@ -0,0 +1,147 @@
+# This model is the master routine for uploading products
+# Requires Paperclip and FasterCSV to upload the CSV file and read it nicely.
+
+# Author:: Josh McArthur
+# License:: MIT
+
+class ProductImport < ActiveRecord::Base
+ has_attached_file :data_file, :path => ":rails_root/lib/etc/product_data/data-files/:basename.:extension"
+ validates_attachment_presence :data_file
+
+ require 'fastercsv'
+ require 'pp'
+
+ ## Data Importing:
+ # Supplier, room and category are all taxonomies to be found (or created) and associated
+ # Model maps to product name, description, brochure text and bullets 1 - 8 are combined to form description
+ # List Price maps to Master Price, Current MAP to Cost Price, Net 30 Cost unused
+ # Width, height, Depth all map directly to object
+ # Image main is created independtly, then each other image also created and associated with the product
+ # Meta keywords and description are created on the product model
+
+ def import_data
+ begin
+ #Get products *before* import -
+ @products_before_import = Product.all
+
+
+ rows = FasterCSV.read(self.data_file.path)
+ log("Importing products for #{self.data_file_file_name} began at #{Time.now}")
+ rows[INITIAL_ROWS_TO_SKIP..-1].each do |row|
+ product_information = {}
+
+ #Easy ones first
+ product_information[:sku] = row[COLUMN_MAPPINGS['SKU']]
+ product_information[:name] = row[COLUMN_MAPPINGS['Name']]
+ product_information[:price] = row[COLUMN_MAPPINGS['Master Price']]
+ product_information[:cost_price] = row[COLUMN_MAPPINGS['Cost Price']]
+ product_information[:available_on] = DateTime.now - 1.day #Yesterday to make SURE it shows up
+ product_information[:weight] = row[COLUMN_MAPPINGS['Weight']]
+ product_information[:height] = row[COLUMN_MAPPINGS['Height']]
+ product_information[:depth] = row[COLUMN_MAPPINGS['Depth']]
+ product_information[:width] = row[COLUMN_MAPPINGS['Width']]
+ product_information[:description] = COLUMN_MAPPINGS['Description']
+
+
+ #Create the product skeleton - should be valid
+ product_obj = Product.new(product_information)
+ unless product_obj.valid?
+ log("A product could not be imported - here is the information we have:\n #{ pp product_information}", :error)
+ next
+ end
+
+ #Save the object before creating asssociated objects
+ product_obj.save
+
+ #Now we have all but images and taxons loaded
+ associate_taxon('Category', row[COLUMN_MAPPINGS['Category']], product_obj)
+
+ #Just images
+ find_and_attach_image(row[COLUMN_MAPPINGS['Image Main']], product_obj)
+ find_and_attach_image(row[COLUMN_MAPPINGS['Image 2']], product_obj)
+ find_and_attach_image(row[COLUMN_MAPPINGS['Image 3']], product_obj)
+ find_and_attach_image(row[COLUMN_MAPPINGS['Image 4']], product_obj)
+
+ #Return a success message
+ log("#{product_obj.name} successfully imported.\n")
+ end
+
+ if DESTROY_ORIGINAL_PRODUCTS_AFTER_IMPORT
+ @products_before_import.each { |p| p.destroy }
+ end
+
+ log("Importing products for #{self.data_file_file_name} completed at #{DateTime.now}")
+
+ rescue Exception => exp
+ log("An error occurred during import, please check file and try again. (#{exp.message})\n#{exp.backtrace.join('\n')}", :error)
+ return [:error, "The file data could not be imported. Please check that the spreadsheet is a CSV file, and is correctly formatted."]
+ end
+
+ #All done!
+ return [:notice, "Product data was successfully imported."]
+ end
+
+
+ private
+
+ ### MISC HELPERS ####
+
+ #Log a message to a file - logs in standard Rails format to logfile set up in the import_products initializer
+ #and console.
+ #Message is string, severity symbol - either :info, :warn or :error
+
+ def log(message, severity = :info)
+ @rake_log ||= ActiveSupport::BufferedLogger.new(LOGFILE)
+ message = "[#{Time.now.to_s(:db)}] [#{severity.to_s.capitalize}] #{message}\n"
+ @rake_log.send severity, message
+ puts message
+ end
+
+
+ ### IMAGE HELPERS ###
+
+ ## find_and_attach_image
+ # The theory behind this method is:
+ # - We know where an 'image dump' of high-res images is - could be remote folder, or local
+ # - We know that the provided filename SHOULD be in this folder
+ def find_and_attach_image(filename, product)
+ #Does the file exist? Can we read it?
+ return if filename.blank?
+ filename = PRODUCT_IMAGE_PATH + filename
+ unless File.exists?(filename) && File.readable?(filename)
+ log("Image #{filename} was not found on the server, so this image was not imported.", :warn)
+ return nil
+ end
+
+ #An image has an attachment (duh) and some object which 'views' it
+ product_image = Image.new({:attachment => File.open(filename, 'rb'),
+ :viewable => product,
+ :position => product.images.length
+ })
+
+ product.images << product_image if product_image.save
+ end
+
+
+
+ ### TAXON HELPERS ###
+ def associate_taxon(taxonomy_name, taxon_name, product)
+ master_taxon = Taxonomy.find_by_name(taxonomy_name)
+
+ if master_taxon.nil?
+ master_taxon = Taxonomy.create(:name => taxonomy_name)
+ log("Could not find Category taxonomy, so it was created.", :warn)
+ end
+
+ taxon = Taxon.find_or_create_by_name_and_parent_id_and_taxonomy_id(
+ taxon_name,
+ master_taxon.root.id,
+ master_taxon.id
+ )
+
+ product.taxons << taxon if taxon.save
+ end
+
+
+ ### END TAXON HELPERS ###
+end
@@ -0,0 +1,18 @@
+<h2><%= t('form.product_import.heading') %></h2>
+
+
+<%= render "shared/error_messages", :target => @product_import %>
+<%= form_for(:product_import, :url => admin_product_import_index_path, :method => :post, :html => { :multipart => true }) do |f| %>
+<fieldset>
+ <%= f.field_container :data_file do %>
+ <%= f.label :data_file, t('form.product_import.new.data_file') %>
+ <%= f.file_field :data_file %>
+ <%= f.error_message_on :data_file %>
+ <% end %>
+</fieldset>
+
+<p class="form-buttons">
+ <%= button t("actions.create") %>
+</p>
+
+<% end %>
@@ -0,0 +1,40 @@
+# This file is the thing you have to config to match your application
+
+
+ #Take a look at the data you need to be importing, and then change this hash accordingly
+ #The first column is 0, etc etc.
+ #This is accessed in the import method using COLUMN_MAPPINGS['field'] for niceness and readability
+ #TODO this could probably be marked up in YML
+ COLUMN_MAPPINGS = {
+ 'SKU' => 0,
+ 'Name' => 1,
+ 'Master Price' => 2,
+ 'Cost Price' => 3,
+ 'Weight' => 4,
+ 'Height' => 5,
+ 'Width' => 6,
+ 'Depth' => 7,
+ 'Image Main' => 8,
+ 'Image 2' => 9,
+ 'Image 3' => 10,
+ 'Image 4' => 11,
+ 'Description' => 12,
+ 'Category' => 13
+ }
+
+ #Where are you keeping your master images?
+ #This path is the path that the import code will search for filenames matching those in your CSV file
+ #As each product is saved, Spree (Well, paperclip) grabs it, transforms it into a range of sizes and
+ #saves the resulting files somewhere else - this is just a repository of originals.
+ PRODUCT_IMAGE_PATH = "#{Rails.root}/lib/etc/product-data/product-images/"
+
+ #From experience, CSV files from clients tend to have a few 'header' rows - count them up if you have them,
+ #and enter this number in here - the import script will skip these rows.
+ INITIAL_ROWS_TO_SKIP = 1
+
+ #I would just leave this as is - Logging is useful for a batch job like this - so
+ # useful in fact, that I have put it in a separate log file.
+ LOGFILE = File.join(Rails.root, '/log/', "import_products_#{Rails.env}.log")
+
+ #Set this to true if you want to destroy your existing products after you have finished importing products
+ DESTROY_ORIGINAL_PRODUCTS_AFTER_IMPORT = false
@@ -0,0 +1,7 @@
+en:
+ product_import_index: 'Import Products'
+ form:
+ product_import:
+ heading: 'Import Products from CSV'
+ new:
+ data_file: "Product Data File"
@@ -0,0 +1,5 @@
+Rails.application.routes.draw do
+ namespace :admin do
+ resources :product_import, :only => [:index, :new, :create]
+ end
+end
@@ -0,0 +1,15 @@
+class CreateProductImports < ActiveRecord::Migration
+ def self.up
+ create_table :product_imports do |t|
+ t.string :data_file_file_name
+ t.string :data_file_content_type
+ t.integer :data_file_file_size
+ t.datetime :data_file_updated_at
+ t.timestamps
+ end
+ end
+
+ def self.down
+ drop_table :product_imports
+ end
+end
@@ -0,0 +1,21 @@
+Gem::Specification.new do |s|
+ s.platform = Gem::Platform::RUBY
+ s.name = 'import_products'
+ s.version = '1.0.0'
+ s.summary = "spree_import_products ... imports products. From a CSV file via Spree's Admin interface"
+ #s.description = 'Add (optional) gem description here'
+ s.required_ruby_version = '>= 1.8.7'
+
+ s.author = 'Josh McArthur'
+ s.email = 'josh@3months.com'
+ s.homepage = 'joshmcarthur.me'
+
+ s.files = Dir['CHANGELOG', 'README.md', 'LICENSE', 'lib/**/*', 'app/**/*']
+ s.require_path = 'lib'
+ s.requirements << 'none'
+
+ s.has_rdoc = true
+
+ s.add_dependency('spree_core', '>= 0.30.1')
+ s.add_dependency('fastercsv')
+end
@@ -0,0 +1,17 @@
+require 'spree_core'
+require 'import_products_hooks'
+
+module ImportProducts
+ class Engine < Rails::Engine
+
+ config.autoload_paths += %W(#{config.root}/lib)
+
+ def self.activate
+ Dir.glob(File.join(File.dirname(__FILE__), "../app/**/*_decorator*.rb")) do |c|
+ Rails.env.production? ? require(c) : load(c)
+ end
+ end
+
+ config.to_prepare &method(:activate).to_proc
+ end
+end
Oops, something went wrong.

0 comments on commit 6c17d99

Please sign in to comment.