adapi 0.0.8 → 0.0.9

Sign up to get free protection for your applications and to get access to all the features.
Files changed (55) hide show
  1. data/.gitignore +1 -1
  2. data/README.markdown +2 -1
  3. data/adapi.gemspec +11 -11
  4. data/examples/add_ad_group.rb +0 -0
  5. data/examples/add_bare_ad_group.rb +3 -1
  6. data/examples/add_bare_campaign.rb +6 -4
  7. data/examples/add_campaign.rb +10 -3
  8. data/examples/add_campaign_criteria.rb +5 -5
  9. data/examples/add_invalid_ad_group.rb +4 -6
  10. data/examples/add_invalid_keywords.rb +40 -0
  11. data/examples/add_invalid_text_ad.rb +6 -6
  12. data/examples/add_keywords.rb +0 -0
  13. data/examples/add_negative_campaign_criteria.rb +0 -0
  14. data/examples/add_text_ad.rb +5 -9
  15. data/examples/custom_settings.yml +8 -0
  16. data/examples/customize_configuration.rb +0 -0
  17. data/examples/delete_ad_group.rb +11 -0
  18. data/examples/delete_keyword.rb +0 -0
  19. data/examples/delete_text_ad.rb +13 -0
  20. data/examples/find_ad_group.rb +10 -0
  21. data/examples/find_all_campaigns.rb +0 -0
  22. data/examples/find_bare_campaign.rb +34 -0
  23. data/examples/find_campaign.rb +0 -0
  24. data/examples/find_campaign_ad_groups.rb +0 -0
  25. data/examples/find_campaign_criteria.rb +0 -0
  26. data/examples/find_locations.rb +0 -0
  27. data/examples/log_to_specific_account.rb +0 -0
  28. data/examples/rollback_campaign.rb +0 -0
  29. data/examples/test_diacritics.rb +0 -0
  30. data/examples/update_ad_group.rb +70 -0
  31. data/examples/update_campaign.rb +34 -9
  32. data/examples/update_campaign_ad_groups.rb +137 -0
  33. data/examples/update_campaign_criteria.rb +33 -0
  34. data/examples/update_complete_campaign.rb +177 -0
  35. data/examples/update_text_ad.rb +18 -0
  36. data/lib/adapi/ad/text_ad.rb +39 -49
  37. data/lib/adapi/ad.rb +30 -19
  38. data/lib/adapi/ad_group.rb +110 -30
  39. data/lib/adapi/api.rb +25 -28
  40. data/lib/adapi/campaign.rb +216 -70
  41. data/lib/adapi/campaign_criterion.rb +44 -5
  42. data/lib/adapi/config.rb +1 -4
  43. data/lib/adapi/constant_data/language.rb +11 -2
  44. data/lib/adapi/keyword.rb +60 -1
  45. data/lib/adapi/version.rb +11 -2
  46. data/lib/adapi.rb +10 -7
  47. data/test/{config/adapi.yml.template → fixtures/adapi.yml} +11 -2
  48. data/test/{config/adwords_api.yml.template → fixtures/adwords_api.yml} +6 -2
  49. data/test/integration/find_location_test.rb +1 -1
  50. data/test/unit/ad_group_test.rb +2 -2
  51. data/test/unit/config_test.rb +8 -27
  52. data/test/unit/constant_data/language_test.rb +34 -0
  53. metadata +52 -108
  54. data/examples/update_campaign_status.rb +0 -15
  55. data/lib/savon_monkeypatch.rb +0 -43
data/lib/adapi/api.rb CHANGED
@@ -1,18 +1,29 @@
1
1
  # encoding: utf-8
2
2
 
3
+ # Basic adapi class, parent of all service classes
4
+
3
5
  module Adapi
4
6
  class Api
5
7
  extend ActiveModel::Naming
6
8
  include ActiveModel::Validations
7
- include ActiveModel::Serialization
8
9
  include ActiveModel::Conversion
9
- # TODO include ActiveModel::Dirty
10
10
 
11
11
  LOGGER = Config.setup_logger
12
12
 
13
+ API_EXCEPTIONS = [
14
+ AdsCommon::Errors::ApiException,
15
+ AdsCommon::Errors::HttpError,
16
+ AdwordsApi::Errors::ApiException
17
+ ]
18
+
13
19
  attr_accessor :adwords, :service, :version, :params,
14
20
  :id, :status, :xsi_type
15
21
 
22
+ # Returns hash of attributes for a model instance
23
+ #
24
+ # This is an implementation of ActiveRecord::Base#attributes method.
25
+ # Children of API model customize this method for their own attributes.
26
+ #
16
27
  def attributes
17
28
  { 'status' => status, 'xsi_type' => xsi_type }
18
29
  end
@@ -48,24 +59,7 @@ module Adapi
48
59
  self.send("#{k}=", v)
49
60
  end
50
61
 
51
- # return parameters in hash
52
- # filtered for API calls by default: without :id and :status parameters
53
- # PS: attributes method always returns all specified attributes
54
- #
55
- def data(filtered = true)
56
- data_hash = self.serializable_hash.symbolize_keys
57
-
58
- if filtered
59
- data_hash.delete(:id)
60
- data_hash.delete(:status)
61
- end
62
-
63
- data_hash
64
- end
65
-
66
- # alias to instance method: data
67
- #
68
- alias :to_hash :data
62
+ alias :to_hash :attributes
69
63
 
70
64
  # detects whether the instance has been saved already
71
65
  #
@@ -74,6 +68,9 @@ module Adapi
74
68
  end
75
69
 
76
70
  def self.create(params = {})
71
+ # FIXME deep symbolize_keys, probably through ActiveSupport
72
+ params.symbolize_keys!
73
+
77
74
  api_instance = self.new(params)
78
75
  api_instance.create
79
76
  api_instance
@@ -84,6 +81,8 @@ module Adapi
84
81
  # class
85
82
  #
86
83
  def self.update(params = {})
84
+ params.symbolize_keys!
85
+
87
86
  # PS: updating campaign without finding it is much faster
88
87
  api_instance = self.new()
89
88
  api_instance.id = params.delete(:id)
@@ -109,15 +108,13 @@ module Adapi
109
108
 
110
109
  begin
111
110
  response = @service.mutate(operation)
112
-
113
- rescue AdsCommon::Errors::HttpError => e
114
- self.errors.add(:base, e.message)
115
111
 
116
- # traps any exceptions raised by AdWords API
117
- rescue AdwordsApi::Errors::ApiException => e
118
- error_key = "[#{self.xsi_type.underscore}]"
119
-
120
- self.errors.add(error_key, e.message)
112
+ rescue *API_EXCEPTIONS => e
113
+ # TODO probably obsolete. keep or remove?
114
+ # error_key = self.xsi_type.to_s.underscore rescue :base
115
+ # self.errors.add(error_key, e.message)
116
+
117
+ self.errors.add(:base, e.message)
121
118
  end
122
119
 
123
120
  response
@@ -1,65 +1,99 @@
1
1
  # encoding: utf-8
2
2
 
3
+ # Class for CampaignService
4
+ #
5
+ # https://developers.google.com/adwords/api/docs/reference/latest/CampaignService
6
+
3
7
  module Adapi
4
8
  class Campaign < Api
5
9
 
6
- # http://code.google.com/apis/adwords/docs/reference/latest/CampaignService.Campaign.html
7
- #
8
- attr_accessor :name, :serving_status, :start_date, :end_date, :budget,
9
- :bidding_strategy, :network_setting, :criteria, :ad_groups
10
+ NETWORK_SETTING_KEYS = [ :target_google_search, :target_search_network,
11
+ :target_content_network, :target_content_contextual, :target_partner_search_network ]
12
+
13
+ ATTRIBUTES = [ :name, :status, :serving_status, :start_date, :end_date, :budget,
14
+ :bidding_strategy, :network_setting, :campaign_stats, :criteria, :ad_groups,
15
+ :ad_serving_optimization_status ]
16
+
17
+ attr_accessor *ATTRIBUTES
10
18
 
11
19
  def attributes
12
- super.merge('name' => name, 'start_date' => start_date, 'end_date' => end_date,
13
- 'budget' => budget, 'bidding_strategy' => bidding_strategy,
14
- 'network_setting' => network_setting, 'criteria' => criteria,
15
- 'ad_groups' => ad_groups)
20
+ super.merge Hash[ ATTRIBUTES.map { |k| [k, self.send(k)] } ]
16
21
  end
17
22
 
23
+ alias to_hash attributes
24
+
18
25
  validates_presence_of :name, :status
19
26
  validates_inclusion_of :status, :in => %w{ ACTIVE DELETED PAUSED }
20
27
 
21
28
  def initialize(params = {})
29
+ params.symbolize_keys!
30
+
22
31
  params[:service_name] = :CampaignService
23
32
 
24
33
  @xsi_type = 'Campaign'
25
34
 
26
- %w{ name status start_date end_date budget bidding_strategy
27
- network_setting criteria ad_groups}.each do |param_name|
28
- self.send "#{param_name}=", params[param_name.to_sym]
35
+ ATTRIBUTES.each do |param_name|
36
+ self.send("#{param_name}=", params[param_name])
29
37
  end
30
38
 
31
- # convert bidding_strategy to GoogleApi
32
- # can be either string (just xsi_type) or hash (xsi_type with params)
33
- # TODO validations for xsi_type
34
- #
35
- unless @bidding_strategy.is_a?(Hash)
36
- @bidding_strategy = { :xsi_type => @bidding_strategy }
37
- end
39
+ # HOTFIX backward compatibility with old field for criteria
40
+ @criteria ||= params[:targets] || {}
41
+
42
+ @ad_groups ||= []
38
43
 
39
- if @bidding_strategy[:bid_ceiling] and not @bidding_strategy[:bid_ceiling].is_a?(Hash)
40
- @bidding_strategy[:bid_ceiling] = {
41
- :micro_amount => Api.to_micro_units(@bidding_strategy[:bid_ceiling])
42
- }
44
+ super(params)
45
+ end
46
+
47
+ def start_date=(a_date)
48
+ @start_date = parse_date(a_date) if a_date.present?
49
+ end
50
+
51
+ def end_date=(a_date)
52
+ @end_date = parse_date(a_date) if a_date.present?
53
+ end
54
+
55
+ def parse_date(a_date)
56
+ case a_date
57
+ when DateTime, Date, Time then a_date
58
+ # FIXME distiguish between timestamp and YYYYMMDD string
59
+ else DateTime.parse(a_date).strftime('%Y%m%d')
43
60
  end
61
+ end
44
62
 
45
- # convert budget to GoogleApi
46
- # TODO validations for budget
47
- #
48
- # budget can be integer (amount) or hash
49
- @budget = { :amount => @budget } unless @budget.is_a?(Hash)
50
- @budget[:period] ||= 'DAILY'
51
- if @budget[:amount] and not @budget[:amount].is_a?(Hash)
52
- @budget[:amount] = { :micro_amount => Api.to_micro_units(@budget[:amount]) }
63
+ # setter for converting bidding_strategy to google format
64
+ # can be either string (just xsi_type) or hash (xsi_type with params)
65
+ # TODO validations for xsi_type
66
+ #
67
+ # TODO watch out when doing update. according to documentation:
68
+ # "to modify an existing campaign's bidding strategy, use
69
+ # CampaignOperation.biddingTransition"
70
+ #
71
+ def bidding_strategy=(params = {})
72
+ unless params.is_a?(Hash)
73
+ params = { xsi_type: params }
74
+ else
75
+ if params[:bid_ceiling] and not params[:bid_ceiling].is_a?(Hash)
76
+ params[:bid_ceiling] = {
77
+ micro_amount: Api.to_micro_units(params[:bid_ceiling])
78
+ }
79
+ end
53
80
  end
54
- # PS: not sure if this should be a default. maybe we don't even need it
55
- @budget[:delivery_method] ||= 'STANDARD'
56
81
 
57
- # HOTFIX backward compatibility with old field for criteria
58
- @criteria ||= params[:targets] || {}
82
+ @bidding_strategy = params
83
+ end
59
84
 
60
- @ad_groups ||= []
85
+ # setter for converting budget to GoogleApi
86
+ # budget can be integer (amount) or hash
87
+ #
88
+ def budget=(params = {})
89
+ # if it's single value, it's a budget amount
90
+ params = { amount: params } unless params.is_a?(Hash)
61
91
 
62
- super(params)
92
+ if params[:amount] and not params[:amount].is_a?(Hash)
93
+ params[:amount] = { micro_amount: Api.to_micro_units(params[:amount]) }
94
+ end
95
+
96
+ @budget = params.merge( period: 'DAILY', delivery_method: 'STANDARD' )
63
97
  end
64
98
 
65
99
  # create campaign with ad_groups and ads
@@ -109,29 +143,150 @@ module Adapi
109
143
  end
110
144
  end
111
145
 
112
- return true
146
+ true
113
147
  end
114
148
 
115
- # general method for changing campaign data
116
- # TODO enable updating of all campaign parts at once, same as for Campaign#create method
149
+ # Sets campaign data en masse, including criteria and ad_groups with keywords and ads
150
+ #
151
+ # Warning: campaign data are not refreshed after update! We'd have to do it by get method
152
+ # and that would slow us down. If you want to see latest data, you have to fetch them again
153
+ # manually: Campaign#find or Campaign#find_complete
117
154
  #
118
- # TODO implement class method
155
+ # TODO implement primarily as class method, instance will be just a redirect with campaign_id
119
156
  #
120
157
  def update(params = {})
121
- # HOTFIX can't use current instance, gotta create new one
122
- response = Adapi::Campaign.new().mutate(
123
- :operator => 'SET',
124
- :operand => params.merge(:id => @id)
125
- )
158
+ # REFACTOR for the moment, we use separate campaign object just to prepare and execute
159
+ # campaign update request. This is kinda ugly and should be eventually refactored (if
160
+ # only because of weird transfer of potential errors later when dealing with response).
161
+ #
162
+ # campaign basic data workflow:
163
+ # parse given params by loading them into Campaign.new and reading them back, parsed
164
+ # REFACTOR should be parsed by separate Campaign class method
165
+ #
166
+ campaign = Adapi::Campaign.new(params)
167
+ # HOTFIX remove :service_name param inserted byu initialize method
168
+ params.delete(:service_name)
169
+ # ...and load parsed params back into the hash
170
+ params.keys.each { |k| params[k] = campaign.send(k) }
171
+ params[:id] = @id
126
172
 
127
- return false unless (response and response[:value])
173
+ @criteria = params.delete(:criteria)
174
+ params.delete(:targets)
175
+ @ad_groups = params.delete(:ad_groups) || []
176
+
177
+ @bidding_strategy = params.delete(:bidding_strategy)
128
178
 
129
- # faster than self.find
130
- params.each_pair { |k,v| self.send("#{k}=", v) }
179
+ operation = { operator: 'SET', operand: params }
180
+
181
+ # BiddingStrategy update has slightly different DSL from other params
182
+ # https://developers.google.com/adwords/api/docs/reference/v201109_1/CampaignService.BiddingTransition
183
+ #
184
+ # See this post about BiddingTransition limitations:
185
+ # https://groups.google.com/forum/?fromgroups#!topic/adwords-api/tmRk1m7PbhU
186
+ # "ManualCPC can transition to anything and everything else can only transition to ManualCPC"
187
+ if @bidding_strategy
188
+ operation[:bidding_transition] = { target_bidding_strategy: @bidding_strategy }
189
+ end
190
+
191
+ campaign.mutate(operation)
192
+
193
+ unless campaign.errors.empty?
194
+ self.store_errors(campaign) and return false
195
+ end
196
+
197
+ # update campaign criteria
198
+ if @criteria && @criteria.size > 0
199
+ new_criteria = Adapi::CampaignCriterion.new(
200
+ :campaign_id => @id,
201
+ :criteria => @criteria
202
+ )
203
+
204
+ new_criteria.update!
205
+
206
+ unless new_criteria.errors.empty?
207
+ self.store_errors(new_criteria) and return false
208
+ end
209
+ end
210
+
211
+ result = self.update_ad_groups!(@ad_groups)
212
+
213
+ result
214
+ end
215
+
216
+ # helper method that updates ad_groups. called from Campaign#update method
217
+ #
218
+ def update_ad_groups!(ad_groups = [])
219
+ return true if ad_groups.nil? or ad_groups.empty?
220
+
221
+ # FIXME deep symbolize_keys
222
+ ad_groups.map! { |ag| ag.symbolize_keys }
223
+
224
+ # check if every ad_group has either :id or :name parameter
225
+ ad_groups.each do |ag|
226
+ if ag[:id].blank? && ag[:name].blank?
227
+ self.errors.add("AdGroup", "required parameter (:id or :name) is missing")
228
+ return false
229
+ end
230
+ end
231
+
232
+ # get current ad_groups
233
+ original_ad_groups = AdGroup.find(:all, :campaign_id => @id)
234
+
235
+ ad_groups.each do |ad_group_data|
236
+ ad_group_data[:campaign_id] = @id
237
+
238
+ # try to find campaign ad_group by id or name
239
+ k, v = ad_group_data.has_key?(:id) ? [:id, ad_group_data[:id]] : [:name, ad_group_data[:name]]
240
+ ad_group = original_ad_groups.find { |ag| ag[k] == v }
241
+
242
+ # update existing ad_group
243
+ if ad_group.present?
244
+ ad_group.update(ad_group_data)
245
+
246
+ original_ad_groups.delete_if { |ag| ag[k] == v }
247
+
248
+ # create non-existent ad_group
249
+ # TODO report error if searching by :id, because such ad_group should exists?
250
+ else
251
+ ad_group_data.delete(:id)
252
+ ad_group = AdGroup.create(ad_group_data)
253
+ end
254
+
255
+ unless ad_group.errors.empty?
256
+ self.store_errors(ad_group, "AdGroup \"#{ad_group[:id] || ad_group[:name]}\"") and return false
257
+ end
258
+ end
259
+
260
+ # delete ad_groups which haven't been updated
261
+ original_ad_groups.each do |ag|
262
+ unless ag.delete
263
+ self.errors.add("AdGroup #{ag[:id]}", "could not be deleted")
264
+ self.store_errors(ad_group, "AdGroup #{ag[:id]}")
265
+ return false
266
+ end
267
+ end
131
268
 
132
269
  true
133
270
  end
134
271
 
272
+ # Shortcut for pattern used in Campaign#update method
273
+ # When partial update fails, store errors in main campaign instance
274
+ #
275
+ def store_errors(failed_instance, error_prefix = nil)
276
+ raise "Campaign#store_errors: Invalid object instance" unless failed_instance.respond_to?(:errors)
277
+
278
+ error_prefix ||= failed_instance.respond_to?(:xsi_type) ? failed_instance.xsi_type : nil
279
+
280
+ failed_instance.errors.messages.each_pair do |k, v|
281
+ k = "#{error_prefix}::#{k}" if error_prefix and (k != :base)
282
+
283
+ Array(v).each do |x|
284
+ self.errors.add(k, x)
285
+ end
286
+ end
287
+ end
288
+
289
+
135
290
  def activate; update(:status => 'ACTIVE'); end
136
291
  def pause; update(:status => 'PAUSED'); end
137
292
 
@@ -163,7 +318,6 @@ module Adapi
163
318
  Campaign.find(:first, :id => @id)
164
319
  end
165
320
 
166
-
167
321
  # if nothing else than single number or string at the input, assume it's an
168
322
  # id and we want to find campaign by id
169
323
  #
@@ -185,10 +339,20 @@ module Adapi
185
339
  end
186
340
  end.compact
187
341
 
188
- # TODO display the rest of the data
189
- # TODO get NetworkSetting - setting as in fields doesn't work
342
+ # TODO make configurable (but for the moment, return everything)
343
+ select_fields = %w{ Id Name Status ServingStatus
344
+ StartDate EndDate AdServingOptimizationStatus }
345
+ # retrieve CampaignStats fields
346
+ select_fields += %w{ Clicks Impressions Cost Ctr }
347
+ # retrieve Budget fields
348
+ select_fields += %w{ Amount Period DeliveryMethod }
349
+ # retrieve BiddingStrategy fields
350
+ select_fields += %w{ BiddingStrategy BidCeiling EnhancedCpcEnabled }
351
+ # retrieve NetworkSetting fields
352
+ select_fields += NETWORK_SETTING_KEYS.map { |k| k.to_s.camelize }
353
+
190
354
  selector = {
191
- :fields => ['Id', 'Name', 'Status', 'BiddingStrategy'],
355
+ :fields => select_fields,
192
356
  :ordering => [{:field => 'Name', :sort_order => 'ASCENDING'}],
193
357
  :predicates => predicates
194
358
  }
@@ -224,23 +388,5 @@ module Adapi
224
388
  campaign
225
389
  end
226
390
 
227
- # Converts campaign data to hash - of the same structure which is used when
228
- # creating a campaign.
229
- #
230
- # PS: could be implemented more succintly, but let's leave it like this for
231
- # now, code can change and this is more readable
232
- #
233
- def to_hash
234
- {
235
- :id => self[:id],
236
- :name => self[:name],
237
- :status => self[:status],
238
- :budget => self[:budget],
239
- :bidding_strategy => self[:bidding_strategy],
240
- :criteria => self[:criteria],
241
- :ad_groups => self[:ad_groups]
242
- }
243
- end
244
-
245
391
  end
246
392
  end
@@ -39,7 +39,7 @@ module Adapi
39
39
  super(params)
40
40
  end
41
41
 
42
- def create
42
+ def create(operator = 'ADD')
43
43
  # step 1 - convert input hash to new array of criteria
44
44
  # example: :language => [ :en, :cs ] -> [ [:language, :en], [:language, :cs] ]
45
45
  criteria_array = []
@@ -124,7 +124,7 @@ module Adapi
124
124
  # step 2 - convert individual criteria to low-level google params
125
125
  operations = criteria_array.map do |criterion_type, criterion_settings|
126
126
  {
127
- :operator => 'ADD',
127
+ :operator => operator,
128
128
  :operand => {
129
129
  :campaign_id => @campaign_id,
130
130
  :criterion => CampaignCriterion::create_criterion(criterion_type, criterion_settings)
@@ -136,7 +136,48 @@ module Adapi
136
136
 
137
137
  (response and response[:value]) ? true : false
138
138
  end
139
+
140
+ # custom update method, which delete all current criteria and adds new ones
141
+ #
142
+ def update!
143
+ result = self.delete_all!
144
+
145
+ # TODO return error if result == false
146
+
147
+ self.create
148
+ end
149
+
150
+ # REFACTOR
151
+ def destroy
152
+ self.create('REMOVE')
153
+ end
139
154
 
155
+ # Deletes all current campaign criteria
156
+ #
157
+ def delete_all!
158
+ # find all current criteria and extract operand params from them
159
+ original_criteria = CampaignCriterion.find(:campaign_id => @campaign_id).map do |criterion|
160
+ criterion.select { |k,v| [ :xsi_type, :id ].include?(k) }
161
+ end
162
+
163
+ # HOTFIX temporarily remove platforms, adwords api throws error on no platforms
164
+ original_criteria.delete_if { |c| c[:xsi_type] == "Platform" }
165
+
166
+ operations = original_criteria.map do |criterion|
167
+ {
168
+ :operator => 'REMOVE',
169
+ :operand => {
170
+ :campaign_id => @campaign_id,
171
+ :criterion => criterion
172
+ }
173
+ }
174
+ end
175
+
176
+ response = self.mutate(operations)
177
+
178
+ (response and response[:value]) ? true : false
179
+ end
180
+
140
181
  def self.find(params = {})
141
182
  params.symbolize_keys!
142
183
 
@@ -154,10 +195,8 @@ module Adapi
154
195
  end
155
196
  end.compact
156
197
 
157
- # TODO: get more fields - tricky, because value files differ for most types
158
- #
198
+ # TODO list all applicable fields in select fields
159
199
  selector = {
160
- # HOTFIX added LocationName - and values for other criterion types mysteriously appeared as well!
161
200
  :fields => ['Id', 'CriteriaType', 'KeywordText', 'LocationName'],
162
201
  :ordering => [{:field => 'Id', :sort_order => 'ASCENDING'}],
163
202
  :predicates => predicates
data/lib/adapi/config.rb CHANGED
@@ -1,9 +1,6 @@
1
1
  # encoding: utf-8
2
2
 
3
- # This class hold configuration data for AdWords API
4
-
5
- # TODO enable this way of using configuration
6
- # Adapi::Campaign.create(:data => campaign_data, :account => :my_account_alias)
3
+ # This class holds configuration data for AdWords API
7
4
 
8
5
  module Adapi
9
6
  class Config
@@ -1,5 +1,15 @@
1
1
  # encoding: utf-8
2
2
 
3
+ # ConstantData::Language provides searching language id by its code. It is used
4
+ # a a helper function for targeting.
5
+ #
6
+ # PS: Previously it has been possible to target by language code. This is not
7
+ # supported by AdWords API anymore.
8
+
9
+ # TODO refactor in tests and targeting code as well, self.find should return nil
10
+ # if nothing is found
11
+ # TODO maybe refactor through Array#assoc method
12
+
3
13
  module Adapi
4
14
  class ConstantData::Language < ConstantData
5
15
 
@@ -22,11 +32,10 @@ module Adapi
22
32
  super(params)
23
33
  end
24
34
 
25
- # Returns AdWords API language id based for language code
35
+ # Finds Language instance by language code or language id (numeric argument)
26
36
  #
27
37
  def self.find(code)
28
38
 
29
- # let's also allow searching by id
30
39
  if code.is_a?(Integer)
31
40
  Language.new(
32
41
  :id => code,
data/lib/adapi/keyword.rb CHANGED
@@ -84,13 +84,72 @@ module Adapi
84
84
 
85
85
  response = self.mutate(operations)
86
86
 
87
- return false unless (response and response[:value])
87
+ # check for PolicyViolationErrors, set exemptions and try again
88
+ # TODO for now, this is only done once. how about setting a number of retries?
89
+ unless self.errors[:PolicyViolationError].empty?
90
+ # FIXME this works, but add exemptions_requests only for related keyword if possible
91
+ operations.each_with_index do |operation, i|
92
+ operations[i][:exemption_requests] = self.errors[:PolicyViolationError].map do |error_key|
93
+ { :key => error_key }
94
+ end
95
+ end
96
+
97
+ self.errors.clear
98
+
99
+ response = self.mutate(operations)
100
+ end
101
+
102
+ return false unless self.errors.empty?
88
103
 
89
104
  self.keywords = response[:value].map { |keyword| keyword[:criterion] }
90
105
 
91
106
  true
92
107
  end
93
108
 
109
+ # keywords mutate wrapper, deals with PolicyViolations for ads
110
+ #
111
+ # REFACTOR use just one mutate method in Api class
112
+ #
113
+ def mutate(operation)
114
+ operation = [operation] unless operation.is_a?(Array)
115
+
116
+ # fix to save space during specifyng operations
117
+ operation = operation.map do |op|
118
+ op[:operand].delete(:status) if op[:operand][:status].nil?
119
+ op
120
+ end
121
+
122
+ begin
123
+
124
+ response = @service.mutate(operation)
125
+
126
+ rescue *API_EXCEPTIONS => e
127
+
128
+ # return PolicyViolations in specific format so they can be sent again
129
+ # see adwords-api gem example for details: handle_policy_violation_error.rb
130
+ e.errors.each do |error|
131
+ # error[:xsi_type] seems to be broken, so using also alternative key
132
+ # also could try: :"@xsi:type" (but api_error_type seems to be more robust)
133
+ if (error[:xsi_type] == 'PolicyViolationError') || (error[:api_error_type] == 'PolicyViolationError')
134
+ if error[:is_exemptable]
135
+ self.errors.add(:PolicyViolationError, error[:key])
136
+ end
137
+
138
+ # return also exemptable errors, operation may fail even with them
139
+ self.errors.add(:base, "violated %s policy: \"%s\" on \"%s\"" % [
140
+ error[:is_exemptable] ? 'exemptable' : 'non-exemptable',
141
+ error[:key][:policy_name],
142
+ error[:key][:violating_text]
143
+ ])
144
+ else
145
+ self.errors.add(:base, e.message)
146
+ end
147
+ end # of errors.each
148
+ end
149
+
150
+ response
151
+ end
152
+
94
153
  def self.find(amount = :all, params = {})
95
154
  params[:format] ||= :google # default, don't do anything with the data from google
96
155
 
data/lib/adapi/version.rb CHANGED
@@ -1,12 +1,21 @@
1
1
  # encoding: utf-8
2
2
 
3
3
  module Adapi
4
- VERSION = "0.0.8"
4
+ VERSION = "0.0.9"
5
5
 
6
6
  # CHANGELOG:
7
7
  #
8
+ # 0.0.9
9
+ # added Campaign#update method - updates campaign, criteria and ad groups by a single method call
10
+ # fixed PolicyViolation exemptions for keywords and ads
11
+ # fixed and refactored error handling for most models
12
+ # refactored AdWords model attributes structure, simplify the code
13
+ # update find methods for most models to display all attributes
14
+ # updated gem dependencies (i.e. google-adwords-api to 0.6.2)
15
+ # refactor SOAP logging - get rid of monkeypatch
16
+ #
8
17
  # 0.0.8
9
- # updated to AdWords API version v201109
18
+ # updated to AdWords API version v201109_1
10
19
  # updated gem dependencies
11
20
  # removed obsolete monkeypatches
12
21
  # improved SOAP logging - enable pretty logging and configurable log path