neuron-client 0.2.6 → 0.3.0

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.
Files changed (116) hide show
  1. data/README.md +11 -8
  2. data/lib/neuron-client/api.rb +5 -0
  3. data/lib/neuron-client/model/ad.rb +142 -0
  4. data/lib/neuron-client/model/ad_calculations.rb +325 -0
  5. data/lib/neuron-client/model/ad_zone.rb +45 -0
  6. data/lib/neuron-client/model/base.rb +233 -18
  7. data/lib/neuron-client/model/blocked_referer.rb +20 -0
  8. data/lib/neuron-client/model/blocked_user_agent.rb +21 -0
  9. data/lib/neuron-client/model/geo_target.rb +36 -0
  10. data/lib/neuron-client/model/pixel.rb +22 -0
  11. data/lib/neuron-client/model/report.rb +35 -0
  12. data/lib/neuron-client/model/s3_file.rb +30 -0
  13. data/lib/neuron-client/model/zone.rb +64 -0
  14. data/lib/neuron-client/model/zone_calculations.rb +37 -0
  15. data/lib/neuron-client/schema/ad.rb +406 -0
  16. data/lib/neuron-client/schema/ad_zone.rb +49 -0
  17. data/lib/neuron-client/schema/blocked_referer.rb +52 -0
  18. data/lib/neuron-client/schema/blocked_user_agent.rb +64 -0
  19. data/lib/neuron-client/schema/common.rb +220 -0
  20. data/lib/neuron-client/schema/event.rb +17 -0
  21. data/lib/neuron-client/schema/geo_target.rb +33 -0
  22. data/lib/neuron-client/schema/pixel.rb +39 -0
  23. data/lib/neuron-client/schema/report.rb +59 -0
  24. data/lib/neuron-client/schema/s3_file.rb +87 -0
  25. data/lib/neuron-client/schema/zone.rb +214 -0
  26. data/lib/neuron-client/version.rb +1 -1
  27. data/lib/neuron-client.rb +24 -59
  28. data/neuron-client.gemspec +3 -0
  29. data/spec/lib/admin_connection_spec.rb +234 -0
  30. data/spec/lib/api_spec.rb +41 -63
  31. data/spec/lib/model/ad_calculations_spec.rb +1146 -0
  32. data/spec/lib/model/ad_spec.rb +253 -0
  33. data/spec/lib/model/ad_zone_spec.rb +15 -0
  34. data/spec/lib/model/base_spec.rb +5 -83
  35. data/spec/lib/model/blocked_referer_spec.rb +36 -0
  36. data/spec/lib/model/blocked_user_agent_spec.rb +36 -0
  37. data/spec/lib/model/geo_target_spec.rb +28 -0
  38. data/spec/lib/model/pixel_spec.rb +36 -0
  39. data/spec/lib/model/report_spec.rb +17 -0
  40. data/spec/lib/{s3_file_spec.rb → model/s3_file_spec.rb} +6 -5
  41. data/spec/lib/model/zone_calculations_spec.rb +49 -0
  42. data/spec/lib/model/zone_spec.rb +155 -0
  43. data/spec/lib/schema/ad_spec.rb +515 -0
  44. data/spec/lib/schema/ad_zone_spec.rb +149 -0
  45. data/spec/lib/schema/blocked_referer_spec.rb +136 -0
  46. data/spec/lib/schema/blocked_user_agent_spec.rb +147 -0
  47. data/spec/lib/schema/geo_target_spec.rb +92 -0
  48. data/spec/lib/schema/pixel_spec.rb +125 -0
  49. data/spec/lib/schema/report_spec.rb +129 -0
  50. data/spec/lib/schema/s3_file_spec.rb +164 -0
  51. data/spec/lib/schema/zone_spec.rb +243 -0
  52. data/spec/spec_helper.rb +2 -1
  53. metadata +141 -121
  54. data/lib/neuron-client/model/admin/ad.rb +0 -22
  55. data/lib/neuron-client/model/admin/ad_zone.rb +0 -15
  56. data/lib/neuron-client/model/admin/base.rb +0 -91
  57. data/lib/neuron-client/model/admin/blocked_referer.rb +0 -12
  58. data/lib/neuron-client/model/admin/blocked_user_agent.rb +0 -12
  59. data/lib/neuron-client/model/admin/geo_target.rb +0 -16
  60. data/lib/neuron-client/model/admin/pixel.rb +0 -12
  61. data/lib/neuron-client/model/admin/report.rb +0 -15
  62. data/lib/neuron-client/model/admin/s3_file.rb +0 -12
  63. data/lib/neuron-client/model/admin/zone.rb +0 -15
  64. data/lib/neuron-client/model/common/ad.rb +0 -42
  65. data/lib/neuron-client/model/common/ad_calculations.rb +0 -329
  66. data/lib/neuron-client/model/common/ad_zone.rb +0 -17
  67. data/lib/neuron-client/model/common/base.rb +0 -67
  68. data/lib/neuron-client/model/common/blocked_referer.rb +0 -16
  69. data/lib/neuron-client/model/common/blocked_user_agent.rb +0 -16
  70. data/lib/neuron-client/model/common/geo_target.rb +0 -16
  71. data/lib/neuron-client/model/common/pixel.rb +0 -18
  72. data/lib/neuron-client/model/common/report.rb +0 -21
  73. data/lib/neuron-client/model/common/s3_file.rb +0 -16
  74. data/lib/neuron-client/model/common/zone.rb +0 -22
  75. data/lib/neuron-client/model/common/zone_calculations.rb +0 -41
  76. data/lib/neuron-client/model/membase/ad.rb +0 -49
  77. data/lib/neuron-client/model/membase/ad_zone.rb +0 -11
  78. data/lib/neuron-client/model/membase/blocked_referer.rb +0 -20
  79. data/lib/neuron-client/model/membase/blocked_user_agent.rb +0 -20
  80. data/lib/neuron-client/model/membase/geo_target.rb +0 -11
  81. data/lib/neuron-client/model/membase/pixel.rb +0 -22
  82. data/lib/neuron-client/model/membase/report.rb +0 -11
  83. data/lib/neuron-client/model/membase/s3_file.rb +0 -11
  84. data/lib/neuron-client/model/membase/zone.rb +0 -30
  85. data/lib/neuron-client/model/models.rb +0 -15
  86. data/spec/lib/model/admin/ad_spec.rb +0 -34
  87. data/spec/lib/model/admin/ad_zone_spec.rb +0 -19
  88. data/spec/lib/model/admin/base_spec.rb +0 -11
  89. data/spec/lib/model/admin/blocked_referer_spec.rb +0 -11
  90. data/spec/lib/model/admin/blocked_user_agent_spec.rb +0 -11
  91. data/spec/lib/model/admin/geo_target_spec.rb +0 -30
  92. data/spec/lib/model/admin/report_spec.rb +0 -21
  93. data/spec/lib/model/admin/s3_spec.rb +0 -11
  94. data/spec/lib/model/admin/zone_spec.rb +0 -21
  95. data/spec/lib/model/common/ad_calculations_spec.rb +0 -1151
  96. data/spec/lib/model/common/ad_spec.rb +0 -11
  97. data/spec/lib/model/common/ad_zone_spec.rb +0 -11
  98. data/spec/lib/model/common/base_spec.rb +0 -11
  99. data/spec/lib/model/common/blocked_referer_spec.rb +0 -11
  100. data/spec/lib/model/common/blocked_user_agent_spec.rb +0 -11
  101. data/spec/lib/model/common/geo_target_spec.rb +0 -11
  102. data/spec/lib/model/common/report_spec.rb +0 -11
  103. data/spec/lib/model/common/s3_spec.rb +0 -11
  104. data/spec/lib/model/common/zone_calculations_spec.rb +0 -54
  105. data/spec/lib/model/common/zone_spec.rb +0 -11
  106. data/spec/lib/model/membase/ad_spec.rb +0 -54
  107. data/spec/lib/model/membase/ad_zone_spec.rb +0 -11
  108. data/spec/lib/model/membase/base_spec.rb +0 -11
  109. data/spec/lib/model/membase/blocked_referer_spec.rb +0 -34
  110. data/spec/lib/model/membase/blocked_user_agent_spec.rb +0 -34
  111. data/spec/lib/model/membase/geo_target_spec.rb +0 -11
  112. data/spec/lib/model/membase/pixel_spec.rb +0 -34
  113. data/spec/lib/model/membase/report_spec.rb +0 -11
  114. data/spec/lib/model/membase/s3_spec.rb +0 -11
  115. data/spec/lib/model/membase/zone_spec.rb +0 -32
  116. data/spec/lib/old_spec.rb +0 -437
data/README.md CHANGED
@@ -11,6 +11,7 @@ Connect to the admin server for read/write access to exposed models:
11
11
  config.connection_type = :admin
12
12
  config.admin_url = "https://example.com"
13
13
  config.admin_key = "secret"
14
+ config.validate = (ENV['RAILS_ENV'] != 'production')
14
15
  end
15
16
 
16
17
  Short form to copy and paste into console:
@@ -32,8 +33,11 @@ Connect to the Membase (or Memcached) Server for limited read access to some exp
32
33
  Create a new API, configure and use it for one specific model:
33
34
 
34
35
  api = Neuron::Client::API.new
35
- api.configure {|config| config.connection_type = :membase; config.membase_servers = '127.0.0.1:11211'}
36
- Neuron::Client::Model::Ad.api = api
36
+ api.configure do |config|
37
+ config.connection_type = :membase
38
+ config.membase_servers = '127.0.0.1:11211'
39
+ end
40
+ Neuron::Client::Ad.api = api
37
41
 
38
42
  Zones
39
43
  =====
@@ -42,23 +46,22 @@ Zones
42
46
 
43
47
  Create a zone:
44
48
 
45
- zone = Neuron::Client::Model::Zone.new(:name => 'test', :response_type => 'Redirect')
49
+ zone = Neuron::Client::Zone.new(:name => 'test', :response_type => 'Redirect', :redirect_url => 'http://example.com')
46
50
  zone.save
47
51
 
48
52
  ... or simply:
49
53
 
50
- Neuron::Client::Model::Zone.create(:name => 'test', :response_type => 'Redirect')
54
+ Neuron::Client::Zone.create(:name => 'test', :response_type => 'Redirect', :redirect_url => 'http://example.com')
51
55
 
52
56
  List all zones:
53
57
 
54
- Neuron::Client::Model::Zone.all # => Array of Zone objects (with limited attributes)
58
+ Neuron::Client::Zone.all # => Array of Zone objects (with limited attributes)
55
59
 
56
60
  Find a zone by ID:
57
61
 
58
- Neuron::Client::Model::Zone.find(zone_id)
62
+ Neuron::Client::Zone.find(zone_id)
59
63
 
60
64
  Update a zone:
61
65
 
62
- zone.update_attributes(:parameters => {'foo' => 'bar'})
66
+ zone.update_attributes(:redirect_url => 'http://example.com/store')
63
67
 
64
- TODO: Finish and finalize the API
@@ -10,6 +10,7 @@ module Neuron
10
10
 
11
11
  configure_admin_connection if config.connection_type == :admin
12
12
  configure_membase_connection if config.connection_type == :membase
13
+ @validate = (config.validate != false)
13
14
 
14
15
  self
15
16
  end
@@ -18,6 +19,10 @@ module Neuron
18
19
  @config.connection_type
19
20
  end
20
21
 
22
+ def validate?
23
+ @validate != false
24
+ end
25
+
21
26
  private
22
27
 
23
28
  def config
@@ -0,0 +1,142 @@
1
+ module Neuron
2
+ module Client
3
+ class Ad
4
+ include Base
5
+ include AdCalculations
6
+
7
+ ACTIVE_TTL = 60 #seconds
8
+ PRESSURE_TTL = 60 #seconds
9
+
10
+ REDIRECT = 'Redirect'
11
+ VIDEO_AD = 'VideoAd'
12
+ VAST_NETWORK = 'VastNetwork'
13
+ ACUDEO_NETWORK = 'AcudeoNetwork'
14
+ RESPONSE_TYPES = [REDIRECT, VIDEO_AD, VAST_NETWORK, ACUDEO_NETWORK]
15
+
16
+ SOCIAL_TYPES = %w(facebook googleplus twitter youtube)
17
+ TIME_ZONES = ActiveSupport::TimeZone.all.collect{|tz| tz.name }
18
+ FREQUENCY_CAP_WINDOWS = %w(Day Hour)
19
+ VAST_TRACKER_TYPES = %w(impression clickTracking firstQuartile midpoint thirdQuartile complete)
20
+
21
+ ATTRIBUTES = [
22
+ # Basics
23
+ :id, # integer
24
+ :approved, # "Yes" or "No"
25
+ :daily_cap, # nil, or integer > 1
26
+ :day_partitions, # nil, or 168 characters of "T" and "F", Sunday first
27
+ :end_datetime, # nil, or a datetime, in UTC
28
+ :frequency_cap, # nil, or {"limit" => 1..10, "window" => in Ad::FREQUENCY_CAP_WINDOWS}
29
+ :geo_target_netacuity_ids, # hash, where keys are in GeoTarget::TYPES, and values are arrays of integer IDs (from NetAcuity)
30
+ :ideal_impressions_per_hour, # nil, or number > 0
31
+ :name, # nil, or string with 255 chars or less
32
+ :overall_cap, # nil, or integer >= 1
33
+ :pixel_ids, # array of integers
34
+ :response_type, # a string, in Ad::RESPONSE_TYPES
35
+ :start_datetime, # a datetime, in UTC
36
+ :time_zone, # a string, in Ad::TIME_ZONES.
37
+ :zone_links, # a hash, with zone_ids as keys, and {"priority" => integer, "weight" => number} hashes as values.
38
+
39
+ # Timestamps
40
+ :created_at, :updated_at, # string, datetime in UTC
41
+
42
+ # Counts
43
+ :today_impressed, # nil, or integer >= 0
44
+ :total_impressed, # nil, or integer >= 0
45
+
46
+ # "Redirect" advertisements must have:
47
+ :redirect_url, # a URI string
48
+
49
+ # "VideoAd" advertisements must have:
50
+ :video_flv_url, # a URI string (no macros)
51
+ :clickthru_url, # a URI string, perhaps with macros
52
+ :companion_ad_html, # nil, or a string (raw html, perhaps with macros)
53
+ :social_urls, # a hash, where keys are in Ad::SOCIAL_TYPES and values are URI strings
54
+ :vast_tracker_urls, # a hash, where keys are in Ad::VAST_TRACKER_TYPES and values are arrays of URI strings
55
+
56
+ # "VastNetwork" advertisements must have:
57
+ :vast_url, # a URI string, perhaps with macros
58
+
59
+ # "AcudeoNetwork" advertisements must have:
60
+ :acudeo_program_id, # a slug
61
+ ]
62
+
63
+ attr_accessor *ATTRIBUTES
64
+
65
+ def attributes
66
+ ATTRIBUTES
67
+ end
68
+
69
+ STATISTIC_TYPES = %w(selections undeliveries impressions redirects clicks)
70
+
71
+ def recent(statistic, by=nil)
72
+ connected_to_admin!
73
+ if validate?
74
+ unless STATISTIC_TYPES.include?(statistic.to_s)
75
+ raise "Unsupported statistic: #{statistic}"
76
+ end
77
+ unless by.nil? || by.to_s == 'zone'
78
+ raise "Unsupported by: #{by}"
79
+ end
80
+ end
81
+ parameters = by.nil? ? {} : {'by' => by.to_s}
82
+ connection.get("ads/#{id}/recent/#{statistic}", parameters)
83
+ end
84
+
85
+ def unlink(zone_id)
86
+ connected_to_admin!
87
+ validate_uuid!(zone_id)
88
+ connection.delete("ads/#{id}/zones/#{zone_id}")
89
+ end
90
+
91
+ def total_impressed
92
+ if connected_to_membase?
93
+ key = "count_delivery_ad_#{id}"
94
+ connection.get(key,1).to_f
95
+ else
96
+ @total_impressed || 0
97
+ end
98
+ end
99
+
100
+ def today_impressed
101
+ if connected_to_membase?
102
+ now_adjusted_for_ad_time_zone = Time.now.in_time_zone(self.time_zone)
103
+ formatted_date = now_adjusted_for_ad_time_zone.strftime('%Y%m%d') # format to YYYYMMDD
104
+ key = "count_delivery_#{formatted_date}_ad_#{id}"
105
+ connection.get(key,1).to_f
106
+ else
107
+ @today_impressed || 0
108
+ end
109
+ end
110
+
111
+ def active?
112
+ if connected_to_membase?
113
+ connection.fetch("Ad:#{id}:active", ACTIVE_TTL) do
114
+ calculate_active?(Time.now, total_impressed, today_impressed)
115
+ end
116
+ else
117
+ calculate_active?(Time.now, total_impressed, today_impressed)
118
+ end
119
+ end
120
+
121
+ def pressure
122
+ if connected_to_membase?
123
+ connection.fetch("Ad:#{id}:pressure", PRESSURE_TTL) do
124
+ calculate_pressure(Time.now, total_impressed, today_impressed)
125
+ end
126
+ else
127
+ calculate_pressure(Time.now, total_impressed, today_impressed)
128
+ end
129
+ end
130
+
131
+ protected
132
+
133
+ def to_update_hash(*except)
134
+ super(:total_impressed, :today_impressed)
135
+ end
136
+
137
+ def to_create_hash(*except)
138
+ super(:total_impressed, :today_impressed)
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,325 @@
1
+ module Neuron
2
+ module Client
3
+ module AdCalculations
4
+ # This module expects the following methods to be defined:
5
+ # start_datetime (Time, String, or nil)
6
+ # end_datetime (Time, String, or nil)
7
+ # time_zone (String)
8
+ # day_partitions (String, or nil, length = 7*24, matches /^[TF]+$/)
9
+ # daily_cap (FixNum, or nil)
10
+ # overall_cap (FixNum, or nil)
11
+ # ideal_impressions_per_hour (Number, or nil)
12
+ # total_impressed (Integer, >= 0)
13
+ # today_impressed (Integer, >= 0)
14
+
15
+ def active?
16
+ calculate_active?(Time.now, total_impressed, today_impressed)
17
+ end
18
+
19
+ def pressure
20
+ calculate_pressure(Time.now, total_impressed, today_impressed)
21
+ end
22
+
23
+ def calculate_active?(time, total, today)
24
+ time = time.in_time_zone(time_zone)
25
+ return false unless within_date_range?(time)
26
+ return false if partitioned? && !partitioned_hour?(time)
27
+ !cap_met?(total, today)
28
+ end
29
+
30
+ # Set the optional "active" parameter to true or false if you already know
31
+ def calculate_pressure(time, total, today, active=nil)
32
+ return nil if active == false
33
+ return nil unless active || calculate_active?(time,total,today)
34
+ time = time.in_time_zone(time_zone)
35
+ if daily_capped?
36
+ if daily_cap_precludes_overall_cap?(time,total,today)
37
+ calculate_overall_pressure(time, total)
38
+ else
39
+ calculate_today_pressure(time, today)
40
+ end
41
+ elsif overall_pressure_exists?
42
+ calculate_overall_pressure(time, total)
43
+ else
44
+ (ideal_impressions_per_hour || 1.0).to_f
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def in_ad_time_zone(time)
51
+ if time.present?
52
+ (time.is_a?(String) ? Time.parse(time) : time).in_time_zone(time_zone)
53
+ end
54
+ rescue
55
+ nil
56
+ end
57
+
58
+ def start_in_time_zone
59
+ in_ad_time_zone(start_datetime)
60
+ end
61
+
62
+ def end_in_time_zone
63
+ in_ad_time_zone(end_datetime)
64
+ end
65
+
66
+ def daily_capped?
67
+ !daily_cap.nil? && daily_cap >= 0
68
+ end
69
+
70
+ def overall_capped?
71
+ !overall_cap.nil? && overall_cap >= 0
72
+ end
73
+
74
+ def overall_pressure_exists?
75
+ overall_capped? && end_datetime.present?
76
+ end
77
+
78
+ def partitioned?
79
+ !day_partitions.nil? && day_partitions.length == 24*7 && day_partitions != "T"*24*7
80
+ end
81
+
82
+ def within_date_range?(time)
83
+ return false if start_datetime.present? && time < start_in_time_zone
84
+ return false if end_datetime.present? && time >= end_in_time_zone
85
+ true
86
+ end
87
+
88
+ # Assume time is in the time zone of the ad.
89
+ # Doesn't worry about whether or not the time is in the ad's date range.
90
+ # Assumes ad is partitioned.
91
+ def partitioned_hour?(time)
92
+ day_partitions.at((time.wday * 24) + time.hour) == 'T'
93
+ end
94
+
95
+ # Assume time is in the time zone of the ad.
96
+ # Doesn't worry about whether or not the time is in the ad's date range.
97
+ # Assumes ad is partitioned.
98
+ # Returns true if there is any active partition the day of the given time.
99
+ def partitioned_day?(time)
100
+ day_partitions[(time.wday) * 24, 24].include?('T')
101
+ end
102
+
103
+ # Assumes time is in the time zone of the ad.
104
+ # Doesn't worry about date range or day partitions.
105
+ def cap_met?(total_impressed, today_impressed)
106
+ return true if daily_capped? && (daily_cap <= today_impressed)
107
+ return true if overall_capped? && (overall_cap <= total_impressed)
108
+ false
109
+ end
110
+
111
+ # Assumes time is in time_zone of the ad.
112
+ # Returns true if there's no way we can hit the overall cap
113
+ # without busting the daily cap.
114
+ def daily_cap_precludes_overall_cap?(time,total_impressed,today_impressed)
115
+ return false unless daily_capped? && overall_capped?
116
+ return false unless self.end_datetime.present?
117
+ a = remaining_impressions_via_daily_cap(time, today_impressed)
118
+ b = remaining_impressions_via_overall_cap(total_impressed)
119
+ a < b
120
+ end
121
+
122
+ def remaining_impressions_via_overall_cap(total_impressed)
123
+ [overall_cap - total_impressed, 0].max
124
+ end
125
+
126
+ def remaining_impressions_today(today_impressed)
127
+ [daily_cap - today_impressed, 0].max
128
+ end
129
+
130
+ def remaining_impressions_via_daily_cap(time, today_impressed)
131
+ days = remaining_days(time) - 1 # exclude today
132
+ daily_cap * days + remaining_impressions_today(today_impressed)
133
+ end
134
+
135
+ # Assumes time is in time_zone of the ad.
136
+ # Assume daily_cap is present.
137
+ def calculate_today_pressure(time, today_impressed)
138
+ hours = remaining_hours_today(time)
139
+ if hours <= 0
140
+ 0.0
141
+ else
142
+ impressions = remaining_impressions_today(today_impressed)
143
+ 2 * impressions / hours.to_f
144
+ end
145
+ end
146
+
147
+ # Assumes time is in time_zone of the ad.
148
+ # Assume overall_cap is present.
149
+ # Assume end_datetime is present.
150
+ def calculate_overall_pressure(time, total_impressed)
151
+ hours = remaining_hours(time)
152
+ if hours <= 0
153
+ 0.0
154
+ else
155
+ impressions = remaining_impressions_via_overall_cap(total_impressed)
156
+ impressions / hours.to_f
157
+ end
158
+ end
159
+
160
+ # remaining_days : the total integer number of days that an ad will run
161
+ # (even for part of the day) between now and the
162
+ # end_datetime.
163
+ # Assume all dates/times are in the time_zone of the ad.
164
+ # Assume end_datetime is present.
165
+ def remaining_days(time)
166
+ ending = end_in_time_zone
167
+ beginning = [time, start_in_time_zone].compact.max
168
+ return 0 unless beginning < ending
169
+ if partitioned?
170
+ end_of_beginning_week = beginning_of_week(beginning) + 7.days
171
+ beginning_of_end_week = beginning_of_week(ending)
172
+
173
+ if end_of_beginning_week < beginning_of_end_week
174
+ head_days = partitioned_days(beginning, end_of_beginning_week)
175
+ tail_days = partitioned_days(beginning_of_end_week, ending)
176
+ whole_weeks = (beginning_of_end_week-end_of_beginning_week) / 7.days
177
+ head_days + (whole_weeks * days_per_week) + tail_days
178
+ else
179
+ partitioned_days(beginning, ending)
180
+ end
181
+ else
182
+ (ending.beginning_of_day.to_datetime -
183
+ beginning.beginning_of_day.to_datetime) + 1
184
+ end
185
+ end
186
+
187
+ # remaining_hours : the total number of hours that an ad will run from now
188
+ # to the end_datetime, taking day parting into account.
189
+ # Assume all dates/times are in the time_zone of the ad.
190
+ def remaining_hours(time)
191
+ ending = end_in_time_zone
192
+ beginning = [time, start_in_time_zone].compact.max
193
+ return 0 unless beginning < ending
194
+ if partitioned?
195
+
196
+ end_of_beginning_week = beginning_of_week(beginning) + 7.days
197
+ beginning_of_end_week = beginning_of_week(ending)
198
+
199
+ if end_of_beginning_week < beginning_of_end_week
200
+ head_hours = partitioned_hours(beginning, end_of_beginning_week)
201
+ tail_hours = partitioned_hours(beginning_of_end_week, ending)
202
+ whole_weeks = (beginning_of_end_week-end_of_beginning_week) / 7.days
203
+ head_hours + (whole_weeks * hours_per_week) + tail_hours
204
+ else
205
+ partitioned_hours(beginning, ending)
206
+ end
207
+ else
208
+ actual_hours(beginning, ending)
209
+ end
210
+ end
211
+
212
+ # remaining_hours_today : the total number of hours that an ad will run
213
+ # between now and the end of the day, taking day
214
+ # parting into account.
215
+ # Assume all dates/times are in the time_zone of the ad.
216
+ def remaining_hours_today(time)
217
+ beginning = [time, start_in_time_zone].compact.max
218
+ ending = [time.beginning_of_day + 1.day, end_in_time_zone].compact.min
219
+ return 0 unless beginning < ending
220
+ if partitioned?
221
+ partitioned_hours(beginning, ending)
222
+ else
223
+ actual_hours(beginning, ending)
224
+ end
225
+ end
226
+
227
+ # Assume time is in the time_zone of the ad.
228
+ # Assume a week begins on Sunday.
229
+ def beginning_of_week(time)
230
+ recent_monday = time.monday
231
+ sunday_before_recent_monday = recent_monday - 1.day
232
+ if (time - sunday_before_recent_monday) < 7.days
233
+ sunday_before_recent_monday
234
+ else
235
+ sunday_before_recent_monday + 7.days
236
+ end
237
+ end
238
+
239
+ def beginning_of_hour(time)
240
+ time.change(:min => 0, :sec => 0, :usec => 0)
241
+ end
242
+
243
+ # Assume beginning and ending are in the ad's time_zone.
244
+ # Assume beginning and ending are within the ad's date range.
245
+ # Assume ad is partitioned.
246
+ def partitioned_hours(beginning, ending)
247
+ return 0 unless beginning < ending
248
+ total = 0.0
249
+ this_hour = beginning_of_hour(beginning)
250
+ last_hour = beginning_of_hour(ending)
251
+ if this_hour.to_i == last_hour.to_i
252
+ if partitioned_hour?(this_hour)
253
+ total = actual_hours(beginning, ending)
254
+ end
255
+ else
256
+ next_hour = this_hour + 1.hour
257
+ if partitioned_hour?(this_hour)
258
+ total += actual_hours(beginning, next_hour)
259
+ end
260
+ each_hour(next_hour, last_hour) do |hour|
261
+ total += 1 if partitioned_hour?(hour)
262
+ end
263
+ if partitioned_hour?(last_hour)
264
+ total += actual_hours(last_hour, ending)
265
+ end
266
+ end
267
+ total
268
+ end
269
+
270
+ # Assume beginning and ending are in the ad's time_zone.
271
+ # Assume beginning and ending are within the ad's date range.
272
+ # Assume ad is partitioned.
273
+ # Assume beginning and ending are within a week of each other.
274
+ # Computes integer number of days where any part of the day is active.
275
+ def partitioned_days(beginning, ending)
276
+ return 0 unless beginning < ending
277
+
278
+ end_of_beginning_day = beginning.beginning_of_day + 1.day
279
+ beginning_of_end_day = ending.beginning_of_day
280
+
281
+ if end_of_beginning_day < beginning_of_end_day
282
+ days = 0
283
+ days += 1 if partitioned_hours(beginning, end_of_beginning_day) > 0
284
+ days += 1 if partitioned_hours(beginning_of_end_day, ending) > 0
285
+ each_day(end_of_beginning_day, beginning_of_end_day) do |day|
286
+ days += 1 if partitioned_day?(day)
287
+ end
288
+ days
289
+ else
290
+ (partitioned_hours(beginning,ending) > 0) ? 1 : 0
291
+ end
292
+ end
293
+
294
+ def each_day(beginning, ending)
295
+ time = beginning.clone
296
+ while (time < ending)
297
+ yield time
298
+ time += 1.day
299
+ end
300
+ end
301
+
302
+ def each_hour(beginning, ending)
303
+ time = beginning.clone
304
+ while(time < ending)
305
+ yield time
306
+ time += 1.hour
307
+ end
308
+ end
309
+
310
+ def actual_hours(beginning, ending)
311
+ (ending.to_f - beginning.to_f) / 3600
312
+ end
313
+
314
+ # Assumes ad is partitioned.
315
+ def days_per_week
316
+ (0..6).map{|d| day_partitions[d*24,24].include?('T') ? 1 : 0 }.sum
317
+ end
318
+
319
+ # Assumes ad is partitioned.
320
+ def hours_per_week
321
+ day_partitions.count("T")
322
+ end
323
+ end
324
+ end
325
+ end
@@ -0,0 +1,45 @@
1
+ module Neuron
2
+ module Client
3
+ class AdZone
4
+ include Base
5
+
6
+ ATTRIBUTES = [
7
+ :ad_id, # integer
8
+ :zone_id, # string, UUID
9
+ :weight, # number
10
+ :priority, # integer, 1..10
11
+
12
+ :created_at, #string, datetime in UTC
13
+ :updated_at, #string, datetime in UTC
14
+ ]
15
+
16
+ attr_accessor *ATTRIBUTES
17
+
18
+ def attributes
19
+ ATTRIBUTES
20
+ end
21
+
22
+ def new_record?
23
+ true
24
+ end
25
+
26
+ def id
27
+ nil
28
+ end
29
+
30
+ def destroy
31
+ connected_to_admin!
32
+ validate_id!(ad_id)
33
+ validate_uuid!(zone_id)
34
+ connection.delete("zones/#{zone_id}/ads/#{ad_id}")
35
+ end
36
+
37
+ def self.unlink(ad_id, zone_id)
38
+ connected_to_admin!
39
+ validate_id!(ad_id)
40
+ validate_uuid!(zone_id)
41
+ connection.delete("zones/#{zone_id}/ads/#{ad_id}")
42
+ end
43
+ end
44
+ end
45
+ end