skimlinks 1.0.0 → 1.0.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 3e74941ec6e62b0229bfd956be8cfa643d4d4f02
4
- data.tar.gz: ca029f6b9c348538b3298f8a893cf767c0455e5c
3
+ metadata.gz: 5d176c89122667e84f50c2a3e4aadc7e7b308fec
4
+ data.tar.gz: 8f2a3b4dfedc721dd626adcd4f67659d5496d621
5
5
  SHA512:
6
- metadata.gz: 020fe91831e6054caa63c00da23493843f2bb35511a228daf90e976ade1dda5e87025ae7ae4cbef6812fcfc2152021806cea9dd77c30d36df4631bdc3ec787e5
7
- data.tar.gz: f65b5b136dd0d69566ad5b576e4c106e1322f520b456487b6572380a09a0aa77adb9a886688b34b7aed2f94cfdf383dcc3011b949bfb5c78713f81074212d223
6
+ metadata.gz: bb8c3bafe621d4f58ceb6536350a40b390ab81ac347703d861931fa0d3ca652651545fedd379a3bfee8f0ed9be202dafe932be94cf63fcf6505fee678b907cc9
7
+ data.tar.gz: d5609beacaba2147aaa5d63c453b91576a226953430e958ead5e1b2cbf54117650d4e71bfef2a930322f1594be803b63d0763753c8b49a66e11e66dcf3626c7a
@@ -1,4 +1,4 @@
1
- Copyright (c) 2012 kraut computing UG (haftungsbeschränkt)
1
+ Copyright (c) 2013 kraut computing UG (haftungsbeschränkt)
2
2
 
3
3
  MIT License
4
4
 
data/README.md CHANGED
@@ -1,5 +1,193 @@
1
1
  # Skimlinks
2
2
 
3
- This gem is no longer public.
3
+ [![Gem Version](https://badge.fury.io/rb/skimlinks.png)](http://badge.fury.io/rb/skimlinks)
4
+ [![Build Status](https://secure.travis-ci.org/krautcomputing/skimlinks.png)](http://travis-ci.org/krautcomputing/skimlinks)
5
+ [![Dependency Status](https://gemnasium.com/krautcomputing/skimlinks.png)](https://gemnasium.com/krautcomputing/skimlinks)
6
+ [![Code Climate](https://codeclimate.com/github/krautcomputing/skimlinks.png)](https://codeclimate.com/github/krautcomputing/skimlinks)
4
7
 
5
- If you want to use it, please get in touch via info@krautcomputing.com
8
+ A simple wrapper around the [Skimlinks APIs](http://skimlinks.com/apis)
9
+
10
+ Read the accompanying [blog post](http://www.krautcomputing.com/blog/2013/01/11/new-gem-skimlinks/).
11
+
12
+ ## Requirements
13
+
14
+ Requires Ruby 1.9.2 or higher
15
+
16
+ ## Installation
17
+
18
+ Add this line to your application's Gemfile:
19
+
20
+ gem 'skimlinks'
21
+
22
+ And then execute:
23
+
24
+ $ bundle
25
+
26
+ Or install it yourself as:
27
+
28
+ $ gem install skimlinks
29
+
30
+ ## APIs
31
+
32
+ Skimlinks offers the following APIs:
33
+
34
+ * The [Product API](http://api-products.skimlinks.com/doc/) to search for products and product categories
35
+ * The [Merchant API](http://api-merchants.skimlinks.com/doc/) to search for merchants and merchant categories
36
+ * The [Link API](http://go.redirectingat.com/doc/) to convert regular URLs into affiliate URLs
37
+ * The [Reporting API](https://api-reports.skimlinks.com/doc/) to receive a history of your earned commissions
38
+
39
+ This gem currently only implements access to the Product API and Merchant API.
40
+
41
+ ## Usage
42
+
43
+ ### Configuration
44
+
45
+ Add configurations to `config/initializers/skimlinks.rb`:
46
+
47
+ ```ruby
48
+ Skimlinks.configure do |config|
49
+ config.api_key = 'foobar' # Your API key (get it here: https://accounts.skimlinks.com/productapi) (mandatory)
50
+ config.cache = Rails.cache # Set to an instance of ActiveSupport::Cache::Store to cache the API requests. (optional, defaults to nil)
51
+ config.format = :json # Currently no other setting is supported. In the future it will be possible to set this to :xml to communicate with the API via XML. (optional, defaults to :json)
52
+ config.cache_ttl = 10.minutes # Set to higher/lower value to cache requests shorter/longer. (optional, defaults to 1 day)
53
+ end
54
+ ```
55
+
56
+ ### Product API
57
+
58
+ #### Get a list of product categories
59
+
60
+ ```ruby
61
+ Skimlinks::ProductSearch.new.categories
62
+
63
+ => {
64
+ => "Animals" => 1, # Category name => category ID
65
+ => "Animals > Live Animals" => 2,
66
+ => "Animals > Pet Supplies" => 3,
67
+ => "Animals > Pet Supplies > Bird Supplies" => 4,
68
+ => "Animals > Pet Supplies > Bird Supplies > Bird Cages & Stands" => 5,
69
+ => "Animals > Pet Supplies > Bird Supplies > Bird Food" => 6,
70
+ => ...
71
+ => }
72
+ ```
73
+
74
+ #### Get a nested list of product categories
75
+
76
+ ```ruby
77
+ Skimlinks::ProductSearch.new.nested_categories
78
+
79
+ => {
80
+ => "Animals" => {
81
+ => "Live Animals" => nil,
82
+ => "Pet Supplies" => {
83
+ => "Bird Supplies" => {
84
+ => "Bird Cages & Stands" => nil,
85
+ => "Bird Food" => nil,
86
+ => ...
87
+ => }
88
+ => }
89
+ => }
90
+ => }
91
+ ```
92
+
93
+ #### Search for products
94
+
95
+ ```ruby
96
+ Skimlinks::ProductSearch.new(
97
+ query: 'justin bieber', # Search query (mandatory)
98
+ page: 1, # Page (optional, defaults to 1)
99
+ rows: 10, # Number of rows to return (optional, max. 300, defaults to 10)
100
+ min_price: 100, # Minimum price (including decimal digits, i.e. 100 = $1.00) (optional)
101
+ max_price: 500, # Maximum price (including decimal digits, i.e. 500 = $5.00) (optional)
102
+ country: 'uk', # Restrict search to products of a specific country (optional)
103
+ merchant_id: 8286, # Restrict search to products of a specific merchant (optional)
104
+ category: 'Toys & Games' # Restrict search to products of a specific category (optional)
105
+ ).products
106
+
107
+ => [
108
+ => #<Skimlinks::Product:0x007ff694913fb0 @description="Justin Bieber (Boyfriend)", @name="Justin Bieber Boyfriend Poster", @currency="gbp", @product_id="8286|33428272", @id="8116651", @merchant_id=8286, @price=219, @url="http://www.play.com/Product.aspx?r=GADG&title=33428272", @category="Toys & Games", @image_urls=[#<URI::HTTP:0x007ff694910540 URL:http://images.productserve.com/preview/1418/569238291.jpg>], @country="UK", @merchant_name="Play.com">,
109
+ => #<Skimlinks::Product:0x007ff694cb98e0 @description="Justin Bieber (Live) Poster", @name="Justin Bieber (Live) Poster", @currency="gbp", @product_id="8286|20468288", @id="7979941", @merchant_id=8286, @price=219, @url="http://www.play.com/Product.aspx?r=GADG&title=20468288", @category="Toys & Games", @image_urls=[#<URI::HTTP:0x007ff694cb9cc8 URL:http://images.productserve.com/preview/1418/153122801.jpg>], @country="UK", @merchant_name="Play.com">,
110
+ => #<Skimlinks::Product:0x007ff694f60fd0 @description="Justin Bieber (Hoodie) Mini Poster", @name="Justin Bieber (Hoodie) Mini Poster", @currency="gbp", @product_id="8286|20418241", @id="7975531", @merchant_id=8286, @price=219, @url="http://www.play.com/Product.aspx?r=GADG&title=20418241", @category="Toys & Games", @image_urls=[#<URI::HTTP:0x007ff694f610c0 URL:http://images.productserve.com/preview/1418/148546541.jpg>], @country="UK", @merchant_name="Play.com">,
111
+ => ...
112
+ => ]
113
+ ```
114
+
115
+ ### Merchant API
116
+
117
+ #### Search for merchants
118
+
119
+ ```ruby
120
+ Skimlinks::MerchantSearch.new(
121
+ category_ids: [1, 2, 3] # Return only merchants in the specificed categories (optional)
122
+ ).merchants
123
+
124
+ => [
125
+ => #<Skimlinks::Merchant:0x00000103d33b60 @id=17738, @name="*NEW!* High Commission Payout!", @preferred={}, @updated_at=2012-12-16 01:02:00 +0100, @average_conversion_rate="0", @average_commission="0", @logo_url="http://s.skimresources.com/logos/17738.jpg", @domains={"9682"=>"mykegelsecret.com", "45143"=>"kegelmasters.com"}, @categories={"37"=>"health & beauty", "1"=>"adult & mature", "38"=>"health & beauty;cosmetics", "39"=>"health & beauty;health products"}, @countries=["united states"], @product_count=0>,
126
+ => #<Skimlinks::Merchant:0x00000103cd7428 @id=41004, @name="Adultsextoys.com - A Huge Range Of Adult Products", @preferred={}, @updated_at=2012-12-16 01:02:00 +0100, @average_conversion_rate="0", @average_commission="0", @logo_url="http://s.skimresources.com/logos/41004.jpg", @domains={"40457"=>"adultsextoys.com.au"}, @categories={"1"=>"adult & mature"}, @countries=["australia"], @product_count=0>,
127
+ => #<Skimlinks::Merchant:0x00000103cb2dd0 @id=68079, @name="Adultshop", @preferred={}, @updated_at=2012-12-16 01:02:00 +0100, @average_conversion_rate="0", @average_commission="0", @logo_url="http://s.skimresources.com/logos/68079.jpg", @domains={"68133"=>"shop.adultshop.de"}, @categories={"1"=>"adult & mature"}, @countries=["germany"], @product_count=0>,
128
+ => ...
129
+ => ]
130
+ ```
131
+
132
+ #### Get a single merchant
133
+
134
+ ```ruby
135
+ Skimlinks::MerchantSearch.new.merchant(
136
+ 12678 # Merchant ID, get it from calling Skimlinks::MerchantSearch.merchants first (mandatory)
137
+ )
138
+
139
+ => #<Skimlinks::Merchant:0x00000105204f30 @id=12678, @name="Amazon US", @preferred={"commission"=>"8.5% General products\r\n4% Electronics", "commissionDetails"=>"Was 6% --&gt; NOW 8.5% General products!\r\nWas 3% --&gt; NOW 4% Electronics!", "description"=>"Amazon.com is the global leader in e-commerce. They launch new product categories and stores around the world as it offers customers greater selection, lower prices, more in-stock merchandise, and a best-in-class shopping experience.", "ecpc"=>"0.00", "featured_commission"=>nil, "pp_enabled"=>"1"}, @updated_at=2012-12-07 01:02:00 +0100, @average_conversion_rate="5.42%", @average_commission="6.36%", @logo_url="http://s.skimresources.com/logos/12678.jpeg", @domains={"6309"=>"amazon.com", "47172"=>"wireless.amazon.com", "119814"=>"amazonsupply.com"}, @categories={"12"=>"consumer electronics;mobiles, pdas & satnav", "50"=>"phones, tv & broadband subscriptions", "8"=>"consumer electronics", "9"=>"consumer electronics;audio, tv & home theatre", "10"=>"consumer electronics;cameras & photos", "11"=>"consumer electronics;gadgets & geeks", "13"=>"consumer electronics;mp3 players & accessories", "18"=>"fashion & accessories", "19"=>"fashion & accessories;belts & bags", "20"=>"fashion & accessories;children's clothing", "21"=>"fashion & accessories;jewelry", "22"=>"fashion & accessories;lingerie & sleepwear", "23"=>"fashion & accessories;men's clothing", "24"=>"fashion & accessories;shoes", "25"=>"fashion & accessories;women's clothing", "33"=>"gifts", "34"=>"gifts;chocolate", "35"=>"gifts;flowers", "36"=>"gifts;novelty", "40"=>"home & garden", "41"=>"home & garden;bed & bath", "42"=>"home & garden;diy", "43"=>"home & garden;furniture & interior design", "44"=>"home & garden;garden", "45"=>"home & garden;home appliances", "37"=>"health & beauty", "38"=>"health & beauty;cosmetics", "39"=>"health & beauty;health products"}, @countries=["united states"], @product_count=0>
140
+ ```
141
+
142
+ #### Get a list of merchant categories
143
+
144
+ ```ruby
145
+ Skimlinks::MerchantSearch.new.categories
146
+
147
+ => {
148
+ => "adult & mature" => 1, # Category name => category ID
149
+ => "arts, crafts & hobbies" => 2,
150
+ => "automotive, cars & bikes" => 3,
151
+ => "baby & parenting supplies" => 4,
152
+ => "books & magazines" => 5,
153
+ => "charities & non-profit" => 6,
154
+ => "computers & software" => 7,
155
+ => "consumer electronics" => 8,
156
+ => "consumer electronics > audio, tv & home theatre" => 9,
157
+ => "consumer electronics > cameras & photos" => 10,
158
+ => ...
159
+ => }
160
+ ```
161
+
162
+ #### Get a nested list of merchant categories
163
+
164
+ ```ruby
165
+ Skimlinks::MerchantSearch.new.nested_categories
166
+
167
+ => {
168
+ => "adult & mature" => nil,
169
+ => "arts, crafts & hobbies" => nil,
170
+ => "automotive, cars & bikes" => nil,
171
+ => "baby & parenting supplies" => nil,
172
+ => "books & magazines" => nil,
173
+ => "charities & non-profit" => nil,
174
+ => "computers & software" => nil,
175
+ => "consumer electronics" => {
176
+ => "audio, tv & home theatre" => nil,
177
+ => "cameras & photos" => nil,
178
+ => ...
179
+ => }
180
+ => }
181
+ ```
182
+
183
+ ## TODO
184
+
185
+ * Implement access to Link API and Reporting API
186
+
187
+ ## Contributing
188
+
189
+ 1. Fork it
190
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
191
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
192
+ 4. Push to the branch (`git push origin my-new-feature`)
193
+ 5. Create new Pull Request
@@ -1,4 +1,24 @@
1
+ require 'gem_config'
2
+ require 'active_support/cache'
3
+
1
4
  module Skimlinks
5
+ include GemConfig::Base
6
+
7
+ ApiError = Class.new(StandardError)
8
+ InvalidParameters = Class.new(StandardError)
9
+
10
+ with_configuration do
11
+ has :api_key, classes: String
12
+ has :format, values: :json, default: :json
13
+ has :cache, classes: ActiveSupport::Cache::Store
14
+ has :cache_ttl, classes: Numeric, default: 1.day
15
+ end
2
16
  end
3
17
 
4
18
  require 'skimlinks/version'
19
+ require 'skimlinks/client'
20
+ require 'skimlinks/merchant'
21
+ require 'skimlinks/product'
22
+ require 'skimlinks/search_helpers'
23
+ require 'skimlinks/merchant_search'
24
+ require 'skimlinks/product_search'
@@ -0,0 +1,208 @@
1
+ require 'json'
2
+ require 'rest-client'
3
+ require 'mechanize'
4
+
5
+ module Skimlinks
6
+ class Client
7
+ API_ENDPOINTS = {
8
+ product_api: 'http://api-product.skimlinks.com/',
9
+ merchant_api: 'http://api-merchants.skimlinks.com/merchants/',
10
+ link_api: 'http://go.productwidgets.com/'
11
+ }
12
+
13
+ attr_accessor *Skimlinks.configuration.rules.keys
14
+
15
+ def initialize(args = {})
16
+ config = Skimlinks.configuration.current.merge(args)
17
+
18
+ Skimlinks.configuration.rules.keys.each do |key|
19
+ self.send "#{key}=", config[key]
20
+ end
21
+
22
+ @product_api = RestClient::Resource.new(API_ENDPOINTS[:product_api])
23
+ @merchant_api = RestClient::Resource.new(API_ENDPOINTS[:merchant_api])
24
+ @mechanize = Mechanize.new do |m|
25
+ m.agent.redirect_ok = false
26
+ m.agent.http.verify_mode = OpenSSL::SSL::VERIFY_NONE
27
+ end
28
+ end
29
+
30
+ # Product API
31
+
32
+ def product_search(args)
33
+ product_count_and_products(args).last
34
+ end
35
+
36
+ def product_count(args)
37
+ product_count_and_products(args.merge(rows: 0)).first
38
+ end
39
+
40
+ def product_categories
41
+ @product_categories ||= product_api('categories')['skimlinksProductAPI']['categories']
42
+ end
43
+
44
+ # Merchant API
45
+
46
+ def merchant_categories
47
+ @merchant_categories ||= merchant_api('categories')
48
+ end
49
+
50
+ def merchant_category_ids
51
+ @merchant_category_ids ||= flatten(self.merchant_categories).grep(/^\d+$/).uniq.map(&:to_i)
52
+ end
53
+
54
+ def merchants_in_category(category_id)
55
+ [].tap do |merchants|
56
+ start, found = 0, nil
57
+
58
+ while found.nil? || start < found
59
+ data = merchant_api('category', category_id, 'limit', 200, 'start', start)
60
+
61
+ merchants.concat data['merchants'] if data['merchants'].present?
62
+
63
+ start = data['numStarted'].to_i + data['numReturned'].to_i
64
+ found = data['numFound']
65
+ end
66
+ end
67
+ end
68
+
69
+ def merchant_search(query, preferred = false)
70
+ [].tap do |merchants|
71
+ start, found = 0, nil
72
+
73
+ while found.nil? || start < found
74
+ data = merchant_api('search', query, 'limit', 200, 'start', start, preferred ? '?filter_by=preferred' : nil)
75
+
76
+ merchants.concat data['merchants'] if data['merchants'].present?
77
+
78
+ start = data['numStarted'].to_i + data['numReturned'].to_i
79
+ found = data['numFound']
80
+ end
81
+ end
82
+ end
83
+
84
+ # Link API
85
+
86
+ def affiliate(url, publisher_id)
87
+ link_api url, publisher_id
88
+ end
89
+
90
+ private
91
+
92
+ def flatten(object)
93
+ case object
94
+ when Hash
95
+ object.to_a.map { |v| flatten(v) }.flatten
96
+ when Array
97
+ object.flatten.map { |v| flatten(v) }
98
+ else
99
+ object
100
+ end
101
+ end
102
+
103
+ def returning_json(&block)
104
+ JSON.parse block.call
105
+ end
106
+
107
+ def returning_xml(&block)
108
+ Nokogiri::XML block.call
109
+ end
110
+
111
+ def product_count_and_products(args)
112
+ api_query = []
113
+ api_query << %(id:(#{args[:ids].join(' ')})) if args[:ids].present?
114
+ api_query << %((#{%w(title description).map { |field| %(#{field}:"#{args[:query]}") }.join(' OR ')})) if args[:query].present?
115
+ api_query << %(price:[#{args[:min_price].presence || '*'} TO #{args[:max_price].presence || '*'}]) if args[:min_price].present? || args[:max_price].present?
116
+ api_query << %(categoryId:(#{args[:category_ids].join(' ')})) if args[:category_ids].present?
117
+ api_query << %(merchantId:"#{args[:merchant_id]}") if args[:merchant_id].present?
118
+ api_query << %(country:"#{args[:country]}") if args[:country].present?
119
+ api_query << %(currency:"#{args[:currency]}") if args[:currency].present?
120
+
121
+ # TODO: Check for categoryId 0, '' or nil, missing categoryId
122
+
123
+ query_params = {
124
+ q: api_query.join(' AND ')
125
+ }
126
+ query_params[:rows] = args[:rows] if args[:rows].present?
127
+ query_params[:start] = args[:start] if args[:start].present?
128
+
129
+ product_data = product_api('query', query_params)['skimlinksProductAPI']
130
+
131
+ [product_data['numFound'], product_data['products']]
132
+ end
133
+
134
+ def get(api, path, params = {})
135
+ raise Skimlinks::InvalidParameters, 'Only JSON format is supported right now.' unless Skimlinks.configuration.format == :json
136
+
137
+ do_get = lambda do
138
+ returning_json do
139
+ api[URI.escape(path)].get params: params
140
+ end
141
+ end
142
+
143
+ if Skimlinks.configuration.cache.nil?
144
+ do_get.call
145
+ else
146
+ cache_key = [
147
+ 'skimlinks',
148
+ 'api',
149
+ Digest::MD5.hexdigest(api.to_s + path + params.to_s)
150
+ ].join(':')
151
+ cache_options = Skimlinks.configuration.cache_ttl > 0 ? { expires_in: Skimlinks.configuration.cache_ttl } : {}
152
+ Skimlinks.configuration.cache.fetch cache_key, cache_options do
153
+ do_get.call
154
+ end
155
+ end
156
+ rescue RestClient::Exception => e
157
+ message = [e.message].tap do |message_parts|
158
+ error = JSON.parse(e.response)['skimlinksProductAPI']['message'] rescue nil
159
+ message_parts << error if error.present?
160
+ end.join(' - ')
161
+ raise Skimlinks::ApiError, message
162
+ end
163
+
164
+ def product_api(method, params = {})
165
+ raise Skimlinks::InvalidParameters, 'API key not configured' if Skimlinks.configuration.api_key.blank?
166
+
167
+ params = params.reverse_merge(
168
+ format: Skimlinks.configuration.format,
169
+ key: Skimlinks.configuration.api_key
170
+ )
171
+
172
+ get(@product_api, method, params).tap do |response|
173
+ raise Skimlinks::InvalidParameters, 'API key is invalid' if response.is_a?(Array) && response.first =~ /^Invalid API key/
174
+ end
175
+ end
176
+
177
+ def merchant_api(method, *params)
178
+ raise Skimlinks::InvalidParameters, 'API key not configured' if Skimlinks.configuration.api_key.blank?
179
+
180
+ path = [
181
+ Skimlinks.configuration.format,
182
+ Skimlinks.configuration.api_key,
183
+ method,
184
+ *params.compact
185
+ ].join('/')
186
+
187
+ get(@merchant_api, path).tap do |response|
188
+ raise Skimlinks::InvalidParameters, 'API key is invalid' if response.is_a?(Array) && response.first =~ /^Invalid API key/
189
+ end
190
+ end
191
+
192
+ def link_api(url, publisher_id)
193
+ query_params = { url: CGI.escape(url), id: publisher_id, xs: 1 }
194
+ path = [API_ENDPOINTS[:link_api], URI.encode_www_form(query_params)].join('?')
195
+ response = @mechanize.head(path)
196
+
197
+ raise Skimlinks::ApiError, "Unexpected response code: #{response.code}" unless response.code == '302'
198
+
199
+ redirect_location = response['location']
200
+ case redirect_location
201
+ when url, %r(http://www\.google\.com/search\?q=)
202
+ nil
203
+ else
204
+ redirect_location
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,35 @@
1
+ require 'active_support/hash_with_indifferent_access'
2
+
3
+ module Skimlinks
4
+ class Merchant
5
+ attr_accessor :id, :name, :preferred, :updated_at, :average_conversion_rate, :average_commission, :logo_url, :domains, :categories, :countries, :product_count
6
+
7
+ class << self
8
+ def build_from_api_response(merchant_data)
9
+ merchant_data.map do |merchant|
10
+ self.new \
11
+ id: merchant['merchantID'].to_i,
12
+ name: merchant['merchantName'].presence,
13
+ preferred: HashWithIndifferentAccess.new(merchant['preferred']),
14
+ updated_at: merchant['dateUpdated'].present? ? Time.parse(merchant['dateUpdated']) : nil,
15
+ average_conversion_rate: merchant['averageConversionRate'].presence,
16
+ average_commission: merchant['averageCommission'].presence,
17
+ logo_url: merchant['logo'].presence,
18
+ domains: HashWithIndifferentAccess.new(merchant['domains']),
19
+ categories: HashWithIndifferentAccess.new(merchant['categories']),
20
+ countries: Array(merchant['countries'].presence)
21
+ end.sort_by(&:name)
22
+ end
23
+ end
24
+
25
+ def initialize(args = {})
26
+ args.each do |k, v|
27
+ self.send "#{k}=", v
28
+ end
29
+ end
30
+
31
+ def preferred?
32
+ self.preferred.present?
33
+ end
34
+ end
35
+ end