neuron-client 0.2.6 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
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