ar_loader 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Binary file
Binary file
@@ -0,0 +1,61 @@
1
+ # Copyright:: (c) Autotelik Media Ltd 2011
2
+ # Author :: Tom Statter
3
+ # Date :: Aug 2010
4
+ # License:: MIT
5
+ #
6
+ class LoaderBase
7
+
8
+ attr_accessor :load_object, :value
9
+
10
+ # Enable single column (association) to contain multiple name/value sets in default form :
11
+ # Name1:value1, value2|Name2:value1, value2, value3|Name3:value1, value2
12
+ #
13
+ # E.G.
14
+ # Row for association could have a name (Size/Colour/Sex) with a set of values,
15
+ # and this combination can be expressed multiple times :
16
+ # Size:small,medium,large|Colour:red, green|Sex:Female
17
+
18
+ @@name_value_delim = ':'
19
+ @@multi_value_delim = ','
20
+ @@multi_assoc_delim = '|'
21
+
22
+ def self.set_name_value_delim(x) @@name_value_delim = x; end
23
+ def self.set_multi_value_delim(x) @@multi_value_delim = x; end
24
+ def self.set_multi_assoc_delim(x) @@multi_assoc_delim = x; end
25
+
26
+ def initialize(object)
27
+ @load_object = object
28
+ end
29
+
30
+ # What process a value string from a column.
31
+ # Assigning value(s) to correct association on @load_object.
32
+ # Method map represents a column from a file and it's correlated AR associations.
33
+ # Value string which may contain multiple values for a collection association.
34
+ #
35
+ def process(method_map, value)
36
+ @value = value
37
+
38
+ if(method_map.has_many && method_map.has_many_class && @value)
39
+ # The Generic handler for Associations
40
+ # The actual class of the association so we can find_or_create on it
41
+ assoc_class = method_map.has_many_class
42
+
43
+ puts "Processing Association: #{assoc_class} : #{@value}"
44
+
45
+ @value.split(@@multi_assoc_delim).collect do |lookup|
46
+ # TODO - Don't just rely on 'name' but try different finds as per MethodMappe::insistent_belongs_to ..
47
+ x = assoc_class.find(:first, :conditions => ['lower(name) LIKE ?', "%#{lookup.downcase}%"])
48
+ unless x
49
+ puts "WARNING: #{lookup} in #{assoc_class} NOT found - Not added to #{@load_object.class}"
50
+ next
51
+ end
52
+ @load_object.send( method_map.has_many ) << x
53
+ @load_object.save
54
+ end
55
+ else
56
+ # Nice n simple straight assignment to a column variable
57
+ method_map.assign(@load_object, @value) unless method_map.has_many
58
+ end
59
+ end
60
+
61
+ end
@@ -0,0 +1,47 @@
1
+ # Copyright:: (c) Autotelik Media Ltd 2011
2
+ # Author :: Tom Statter
3
+ # Date :: Jan 2011
4
+ # License:: MIT. Free, Open Source.
5
+ #
6
+ require 'loader_base'
7
+
8
+ class ImageLoader < LoaderBase
9
+
10
+ def initialize(image = nil)
11
+ obj = image || Image.create
12
+ super( obj )
13
+ raise "Failed to create Image for loading" unless @load_object
14
+ end
15
+
16
+ def refresh
17
+ @load_object = Image.create
18
+ end
19
+
20
+ # Note the Spree Image model sets default storage path to
21
+ # => :path => ":rails_root/public/assets/products/:id/:style/:basename.:extension"
22
+
23
+ def process( image_path, record = nil)
24
+
25
+ unless File.exists?(image_path)
26
+ puts "ERROR : Invalid Path"
27
+ return
28
+ end
29
+
30
+ alt = (record and record.respond_to? :name) ? record.name : ""
31
+
32
+ @load_object.alt = alt
33
+
34
+ begin
35
+ @load_object.attachment = File.new(image_path, "r")
36
+ rescue => e
37
+ puts e.inspect
38
+ puts "ERROR : Failed to read image #{image_path}"
39
+ return
40
+ end
41
+
42
+ @load_object.attachment.reprocess!
43
+ @load_object.viewable = record if record
44
+
45
+ puts @load_object.save ? "Success: Uploaded Image: #{@load_object.inspect}" : "ERROR : Problem saving to DB Image: #{@load_object}"
46
+ end
47
+ end
@@ -0,0 +1,93 @@
1
+ # Copyright:: (c) Autotelik Media Ltd 2010
2
+ # Author :: Tom Statter
3
+ # Date :: Aug 2010
4
+ # License:: MIT ?
5
+ #
6
+ # Details:: Specific over-rides/additions to general loader to
7
+ # support Spree Products
8
+ #
9
+ require 'loader_base'
10
+
11
+ class ProductLoader < LoaderBase
12
+
13
+ def initialize(product = nil)
14
+ prod = product || Product.create
15
+ super( prod )
16
+ raise "Failed to create Product for loading" unless @load_object
17
+ end
18
+
19
+ # What process a value string from a column, assigning value(s) to correct association on Product.
20
+ # Method map represents a column from a file and it's correlated Product association.
21
+ # Value string which may contain multiple values for a collection association.
22
+ # Product to assign that value to.
23
+ def process( method_map, value)
24
+ @value = value
25
+
26
+ #puts "DEBUG : process #{method_map.inspect} : #{value.inspect}"
27
+ # Special case for OptionTypes as it's two stage process
28
+ # First add the possible option_types to Product, then we are able
29
+ # to define Variants on those options.
30
+
31
+ if(method_map.name == 'option_types' && @value)
32
+
33
+ option_types = @value.split(@@multi_assoc_delim)
34
+ option_types.each do |ostr|
35
+ oname, value_str = ostr.split(@@name_value_delim)
36
+ option_type = OptionType.find_or_create_by_name(oname)
37
+ unless option_type
38
+ puts "WARNING: OptionType #{oname} NOT found - Not set Product"
39
+ next
40
+ end
41
+
42
+ @load_object.option_types << option_type unless @load_object.option_types.include?(option_type)
43
+
44
+ # Now get the value(s) for the option e.g red,blue,green for OptType 'colour'
45
+ ovalues = value_str.split(',')
46
+ ovalues.each_with_index do |ovname, i|
47
+ ovname.strip!
48
+ ov = OptionValue.find_by_name(ovname)
49
+ if ov
50
+ object = Variant.new( :sku => "#{@load_object.sku}_#{i}", :price => @load_object.price, :available_on => @load_object.available_on)
51
+ #puts "DEBUG: Create New Variant: #{object.inspect}"
52
+ object.option_values << ov
53
+ @load_object.variants << object
54
+ else
55
+ puts "WARNING: Option #{ovname} NOT FOUND - No Variant created"
56
+ end
57
+ end
58
+ end
59
+
60
+ # Special case for ProductProperties since it can have additional value applied.
61
+ # A list of Properties with a optional Value - supplied in form :
62
+ # Property:value|Property2:value|Property3:value
63
+ #
64
+ elsif(method_map.name == 'product_properties' && @value)
65
+
66
+ property_list = @value.split(@@multi_assoc_delim)
67
+
68
+ property_list.each do |pstr|
69
+ pname, pvalue = pstr.split(@@name_value_delim)
70
+ property = Property.find_by_name(pname)
71
+ unless property
72
+ puts "WARNING: Property #{pname} NOT found - Not set Product"
73
+ next
74
+ end
75
+ @load_object.product_properties << ProductProperty.create( :property => property, :value => pvalue)
76
+ end
77
+
78
+ elsif(method_map.name == 'count_on_hand' && @load_object.variants.size > 0 &&
79
+ @value.is_a?(String) && @value.include?(@@multi_assoc_delim))
80
+ # Check if we processed Option Types and assign count per option
81
+ values = @value.split(@@multi_assoc_delim)
82
+ if(@load_object.variants.size == values.size)
83
+ @load_object.variants.each_with_index {|v, i| v.count_on_hand == values[i] }
84
+ else
85
+ puts "WARNING: Count on hand entries does not match number of Variants"
86
+ end
87
+
88
+ else
89
+ super(method_map, value)
90
+ end
91
+
92
+ end
93
+ end
data/lib/to_b.rb ADDED
@@ -0,0 +1,24 @@
1
+ class Object
2
+ def to_b
3
+ case self
4
+ when true, false: self
5
+ when nil: false
6
+ else
7
+ to_i != 0
8
+ end
9
+ end
10
+ end
11
+
12
+ class String
13
+ TRUE_REGEXP = /^(yes|true|on|t|1|\-1)$/i.freeze
14
+ FALSE_REGEXP = /^(no|false|off|f|0)$/i.freeze
15
+
16
+ def to_b
17
+ case self
18
+ when TRUE_REGEXP: true
19
+ when FALSE_REGEXP: false
20
+ else
21
+ to_i != 0
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,138 @@
1
+ require File.dirname(__FILE__) + '/spec_helper'
2
+
3
+ describe 'ExcelLoader' do
4
+
5
+ before do
6
+ @klazz = Product
7
+ MethodMapper.clear
8
+ end
9
+
10
+ it "should populate operators for a given AR model" do
11
+ MethodMapper.find_operators( @klazz )
12
+
13
+ MethodMapper.has_many.should_not be_empty
14
+ MethodMapper.assignments.should_not be_empty
15
+
16
+ hmf = MethodMapper.has_many_for(@klazz)
17
+ arf = MethodMapper.assignments_for(@klazz)
18
+
19
+ (hmf & arf).should_not be_empty # Associations provide << or =
20
+
21
+ hmf.should include('properties')
22
+ arf.should include('count_on_hand') # example of a column
23
+ arf.should include('cost_price') # example of delegated assignment (available through Variant)
24
+
25
+ MethodMapper.column_types.should be_is_a(Hash)
26
+ MethodMapper.column_types.should_not be_empty
27
+
28
+ MethodMapper.column_type_for(@klazz, 'count_on_hand').should_not be_nil
29
+ end
30
+
31
+ it "should populate operators respecting unique option" do
32
+ MethodMapper.find_operators( @klazz, :unique => true )
33
+
34
+ hmf = MethodMapper.has_many_for(@klazz)
35
+ arf = MethodMapper.assignments_for(@klazz)
36
+
37
+ (hmf & arf).should be_empty
38
+ end
39
+
40
+ it "should populate assignment method and col type for different forms of a column name" do
41
+
42
+ MethodMapper.find_operators( @klazz )
43
+
44
+ ["Count On hand", 'count_on_hand', "Count OnHand", "COUNT ONHand"].each do |format|
45
+ mmap = MethodMapper.determine_calls( @klazz, format )
46
+
47
+ mmap.class.should == MethodDetail
48
+
49
+ mmap.assignment.should == 'count_on_hand='
50
+ mmap.has_many.should be_nil
51
+
52
+ mmap.col_type.should_not be_nil
53
+ mmap.col_type.name.should == 'count_on_hand'
54
+ mmap.col_type.default.should == 0
55
+ mmap.col_type.sql_type.should == 'int(10)'
56
+ mmap.col_type.type.should == :integer
57
+ end
58
+ end
59
+
60
+ it "should populate both methods for different forms of an association name" do
61
+
62
+ MethodMapper.find_operators( @klazz )
63
+ ["product_option_types", "product option types", 'product Option_types', "ProductOptionTypes", "Product_Option_Types"].each do |format|
64
+ mmap = MethodMapper.determine_calls( @klazz, format )
65
+
66
+ mmap.assignment.should == 'product_option_types='
67
+ mmap.has_many.should == 'product_option_types'
68
+
69
+ mmap.col_type.should be_nil
70
+ end
71
+ end
72
+
73
+
74
+ it "should not populate anything when non existent column name" do
75
+ ["On sale", 'on_sale'].each do |format|
76
+ mmap = MethodMapper.determine_calls( @klazz, format )
77
+
78
+ mmap.class.should == MethodDetail
79
+ mmap.assignment.should be_nil
80
+ mmap.has_many.should be_nil
81
+ mmap.col_type.should be_nil
82
+ end
83
+ end
84
+
85
+ it "should enable correct assignment and sending of a value to AR model" do
86
+
87
+ MethodMapper.find_operators( @klazz )
88
+
89
+ mmap = MethodMapper.determine_calls( @klazz, 'count on hand' )
90
+ mmap.assignment.should == 'count_on_hand='
91
+
92
+ x = @klazz.new
93
+
94
+ x.should be_new_record
95
+
96
+ x.send( mmap.assignment, 2 )
97
+ x.count_on_hand.should == 2
98
+ x.on_hand.should == 2 # helper method I know looks at same thing
99
+
100
+ mmap = MethodMapper.determine_calls( @klazz, 'SKU' )
101
+ mmap.assignment.should == 'sku='
102
+ x.send( mmap.assignment, 'TEST_SK 001' )
103
+ x.sku.should == 'TEST_SK 001'
104
+ end
105
+
106
+ it "should enable correct assignment and sending of association to AR model" do
107
+
108
+ MethodMapper.find_operators( @klazz )
109
+
110
+ mmap = MethodMapper.determine_calls( @klazz, 'taxons' )
111
+ mmap.has_many.should == 'taxons'
112
+
113
+ x = @klazz.new
114
+
115
+ # NEW ASSOCIATION ASSIGNMENT v.,mn
116
+ x.send( mmap.has_many ) << Taxon.new
117
+ x.taxons.size.should == 1
118
+
119
+ x.send( mmap.has_many ) << [Taxon.new, Taxon.new]
120
+ x.taxons.size.should == 3
121
+
122
+ # EXISTING ASSOCIATIONS
123
+ x = Product.find :first
124
+
125
+ t = Taxonomy.find_or_create_by_name( 'BlahSpecTest' )
126
+
127
+ if x
128
+ sz = x.taxons.size
129
+ x.send(mmap.has_many) << t.root
130
+ x.taxons.size.should == sz + 1
131
+ else
132
+ puts "WARNING : Test not run could not find any Test Products"
133
+ end
134
+
135
+ end
136
+
137
+
138
+ end
@@ -0,0 +1,37 @@
1
+ unless defined? SPREE_ROOT
2
+ ENV["RAILS_ENV"] = "test"
3
+ case
4
+ when ENV["SPREE_ENV_FILE"]
5
+ require ENV["SPREE_ENV_FILE"]
6
+ when File.dirname(__FILE__) =~ %r{vendor/SPREE/vendor/extensions}
7
+ require "#{File.expand_path(File.dirname(__FILE__) + "/../../../../../../")}/config/environment"
8
+ else
9
+ require "#{File.expand_path(File.dirname(__FILE__) + "/../../../../")}/config/environment"
10
+ end
11
+ end
12
+ require "#{SPREE_ROOT}/spec/spec_helper"
13
+
14
+ if File.directory?(File.dirname(__FILE__) + "/scenarios")
15
+ Scenario.load_paths.unshift File.dirname(__FILE__) + "/scenarios"
16
+ end
17
+ if File.directory?(File.dirname(__FILE__) + "/matchers")
18
+ Dir[File.dirname(__FILE__) + "/matchers/*.rb"].each {|file| require file }
19
+ end
20
+
21
+ Spec::Runner.configure do |config|
22
+ # config.use_transactional_fixtures = true
23
+ # config.use_instantiated_fixtures = false
24
+ # config.fixture_path = RAILS_ROOT + '/spec/fixtures'
25
+
26
+ # You can declare fixtures for each behaviour like this:
27
+ # describe "...." do
28
+ # fixtures :table_a, :table_b
29
+ #
30
+ # Alternatively, if you prefer to declare them only once, you can
31
+ # do so here, like so ...
32
+ #
33
+ # config.global_fixtures = :table_a, :table_b
34
+ #
35
+ # If you declare global fixtures, be aware that they will be declared
36
+ # for all of your examples, even those that don't use them.
37
+ end
@@ -0,0 +1,65 @@
1
+ # Author :: Tom Statter
2
+ # Date :: Mar 2011
3
+ #
4
+ # License:: The MIT License (Free and OpenSource)
5
+ #
6
+ # About:: Additional Rake tasks useful when testing seeding DB via ARLoader
7
+ #
8
+ namespace :autotelik do
9
+
10
+ namespace :db do
11
+
12
+ SYSTEM_TABLE_EXCLUSION_LIST = ['schema_migrations']
13
+
14
+ desc "Purge the current database"
15
+ task :purge, :exclude_system_tables, :needs => [:environment] do |t, args|
16
+ require 'highline/import'
17
+
18
+ if(RAILS_ENV == 'production')
19
+ agree("WARNING: In Production database, REALLY PURGE ? [y]:")
20
+ end
21
+
22
+ config = ActiveRecord::Base.configurations[RAILS_ENV || 'development']
23
+ case config['adapter']
24
+ when "mysql", "jdbcmysql"
25
+ ActiveRecord::Base.establish_connection(config)
26
+ ActiveRecord::Base.connection.tables.each do |table|
27
+ next if(args[:exclude_system_tables] && SYSTEM_TABLE_EXCLUSION_LIST.include?(table) )
28
+ puts "purging table: #{table}"
29
+ ActiveRecord::Base.connection.execute("TRUNCATE #{table}")
30
+ end
31
+ when "sqlite","sqlite3"
32
+ dbfile = config["database"] || config["dbfile"]
33
+ File.delete(dbfile) if File.exist?(dbfile)
34
+ when "sqlserver"
35
+ dropfkscript = "#{config["host"]}.#{config["database"]}.DP1".gsub(/\\/,'-')
36
+ `osql -E -S #{config["host"]} -d #{config["database"]} -i db\\#{dropfkscript}`
37
+ `osql -E -S #{config["host"]} -d #{config["database"]} -i db\\#{RAILS_ENV}_structure.sql`
38
+ when "oci", "oracle"
39
+ ActiveRecord::Base.establish_connection(config)
40
+ ActiveRecord::Base.connection.structure_drop.split(";\n\n").each do |ddl|
41
+ ActiveRecord::Base.connection.execute(ddl)
42
+ end
43
+ when "firebird"
44
+ ActiveRecord::Base.establish_connection(config)
45
+ ActiveRecord::Base.connection.recreate_database!
46
+ else
47
+ raise "Task not supported by '#{config["adapter"]}'"
48
+ end
49
+ end
50
+
51
+ desc "Clear database and optional directories such as assets, then run db:seed"
52
+ task :seed_again, :assets, :needs => [:environment] do |t, args|
53
+
54
+ Rake::Task['autotelik:db:purge'].invoke( true ) # i.e ENV['exclude_system_tables'] = true
55
+
56
+ if(args[:assets])
57
+ assets = "#{Rails.root}/public/assets"
58
+ FileUtils::rm_rf(assets) if(File.exists?(assets))
59
+ end
60
+
61
+ Rake::Task['db:seed'].invoke
62
+ end
63
+
64
+ end # db
65
+ end # autotelik
@@ -0,0 +1,101 @@
1
+ # Copyright:: (c) Autotelik Media Ltd 2011
2
+ # Author :: Tom Statter
3
+ # Date :: Feb 2011
4
+ # License:: TBD. Free, Open Source. MIT ?
5
+ #
6
+ # REQUIRES: JRuby
7
+ #
8
+ # Usage from rake : jruby -S rake excel_loader input=<file.xls>
9
+ #
10
+ # e.g. => jruby -S rake excel_load input=vendor\extensions\autotelik\fixtures\ExampleInfoWeb.xls
11
+ # => jruby -S rake excel_load input=C:\MyProducts.xls verbose=true
12
+ #
13
+ namespace :autotelik do
14
+
15
+ desc "Populate AR model's table with data stored in Excel"
16
+ task :excel_load, :klass, :input, :verbose, :sku_prefix, :needs => :environment do |t, args|
17
+
18
+ raise "USAGE: jruby -S rake excel_load input=excel_file.xls" unless args[:input]
19
+ raise "ERROR: Cannot process without AR Model - please supply model=<Class>" unless args[:class]
20
+ raise "ERROR: Could not find file #{args[:input]}" unless File.exists?(args[:input])
21
+
22
+ klass = Kernal.const_get(args[:model])
23
+ raise "ERROR: No such AR Model found - please check model=<Class>" unless(klass)
24
+
25
+ require 'product_loader'
26
+ require 'method_mapper_excel'
27
+
28
+ args[:class]
29
+
30
+ @method_mapper = MethodMapperExcel.new(args[:input], Product)
31
+
32
+ @excel = @method_mapper.excel
33
+
34
+ if(args[:verbose])
35
+ puts "Loading from Excel file: #{args[:input]}"
36
+ puts "Processing #{@excel.num_rows} rows"
37
+ end
38
+
39
+ # TODO create YAML configuration file to drive mandatory columns
40
+ #
41
+ # TODO create YAML configuration file to drive defaults etc
42
+
43
+ # Process spreadsheet and create model instances
44
+
45
+ method_names = @method_mapper.method_names
46
+
47
+ Product.transaction do
48
+ @products = []
49
+
50
+ (1..@excel.num_rows).collect do |row|
51
+
52
+ product_data_row = @excel.sheet.getRow(row)
53
+ break if product_data_row.nil?
54
+
55
+ # Excel num_rows seems to return all 'visible' rows so,
56
+ # we have to manually detect when actual data ends and all the empty rows start
57
+ contains_data = required_methods.find { |mthd| ! product_data_row.getCell(method_names.index(mthd)).to_s.empty? }
58
+ break unless contains_data
59
+
60
+ @assoc_classes = {}
61
+
62
+ loader = ProductLoader.new()
63
+
64
+ # TODO - Smart sorting of column processing order ....
65
+ # Does not currently ensure mandatory columns (for valid?) processed first but model needs saving
66
+ # before associations can be processed so user should ensure mandatory columns are prior to associations
67
+
68
+ @method_mapper.methods.each_with_index do |method_map, col|
69
+
70
+ loader.process(method_map, @excel.value(product_data_row, col))
71
+ begin
72
+ loader.load_object.save if( loader.load_object.valid? && loader.load_object.new_record? )
73
+ rescue
74
+ raise "Error processing Product"
75
+ end
76
+ end
77
+
78
+ product = loader.load_object
79
+
80
+ product.available_on ||= Time.now.to_s(:db)
81
+
82
+ # TODO - handle when it's not valid ?
83
+ # Process rest and dump out an exception list of Products
84
+ #unless(product.valid?)
85
+ #end
86
+
87
+ puts "SAVING ROW #{row} : #{product.inspect}" if args[:verbose]
88
+
89
+ unless(product.save)
90
+ puts product.errors.inspect
91
+ puts product.errors.full_messages.inspect
92
+ raise "Error Saving Product: #{product.sku} :#{product.name}"
93
+ else
94
+ @products << product
95
+ end
96
+ end
97
+ end # TRANSACTION
98
+
99
+ end
100
+
101
+ end
@@ -0,0 +1,38 @@
1
+ # Copyright:: (c) Autotelik Media Ltd 2011
2
+ # Author :: Tom Statter
3
+ # Date :: Feb 2011
4
+ # License:: MIT. Free, Open Source.
5
+ #
6
+ # Usage:: rake autotelik:file_rename input=/blah image_load input=path_to_images
7
+ #
8
+
9
+ namespace :autotelik do
10
+
11
+ desc "copy or mv a folder of files, consistently renaming in the process"
12
+ task :file_rename, :input, :offset, :prefix, :width, :commit, :mv do |t, args|
13
+ raise "USAGE: rake file_rename input='C:\blah' [offset=n prefix='str' width=n]" unless args[:input] && File.exists?(args[:input])
14
+ width = args[:width] || 2
15
+
16
+ action = args[:mv] ? 'mv' : 'cp'
17
+
18
+ cache = args[:input]
19
+
20
+ if(File.exists?(cache) )
21
+ puts "Renaming files from #{cache}"
22
+ Dir.glob(File.join(cache, "*")) do |name|
23
+ path, base_name = File.split(name)
24
+ id = base_name.slice!(/\w+/)
25
+
26
+ id = id.to_i + args[:offset].to_i if(args[:offset])
27
+ id = "%0#{width}d" % id.to_i if(args[:width])
28
+ id = args[:prefix] + id.to_s if(args[:prefix])
29
+
30
+ destination = File.join(path, "#{id}#{base_name}")
31
+ puts "ACTION: #{action} #{name} #{destination}"
32
+
33
+ File.send( action, name, destination) if args[:commit]
34
+ end
35
+ end
36
+ end
37
+
38
+ end
@@ -0,0 +1,15 @@
1
+ p = Product.seed( :name ) do |s|
2
+ s.name = <%= @name %>
3
+ s.available_on = '2009-09-01 09:00:00.0'
4
+ s.meta_keywords = ['training', 'training']
5
+ s.meta_description = ""
6
+ s.description = '<%= @description %>'
7
+ s.price = 0.00
8
+ s.sku = '<%= @sku %>'
9
+
10
+ s.is_physical = false
11
+ s.is_private = false
12
+
13
+ s.append_association :taxons, Taxon.find_by_name( 'Training' )
14
+
15
+ end