trackoid_mongoid4 0.1.3

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.
@@ -0,0 +1,52 @@
1
+ # encoding: utf-8
2
+ class Time
3
+ # Functions to construct the MongoDB field key for trackers
4
+ #
5
+ # to_i_timestamp returns the computed UTC timestamp regardless of the
6
+ # timezone.
7
+ #
8
+ # Examples:
9
+ # 2011-01-01 00:00:00 UTC ===> 14975
10
+ # 2011-01-01 23:59:59 UTC ===> 14975
11
+ # 2011-01-02 00:00:00 UTC ===> 14976
12
+ #
13
+ # to_i_hour returns the hour for the date, again regardless of TZ
14
+ #
15
+ # 2011-01-01 00:00:00 UTC ===> 0
16
+ # 2011-01-01 23:59:59 UTC ===> 23
17
+ #
18
+ ONEHOUR = 60 * 60
19
+ ONEDAY = 24 * ONEHOUR
20
+
21
+ def to_i_timestamp
22
+ #Adding a fix for case where the 'quo' is being used instead of Fixnum's '/' operator
23
+ (self.dup.utc.to_i / ONEDAY).to_i
24
+ end
25
+
26
+ def to_key_timestamp
27
+ to_i_timestamp.to_s
28
+ end
29
+
30
+ def to_i_hour
31
+ self.dup.utc.hour
32
+ end
33
+
34
+ def to_key_hour
35
+ to_i_hour.to_s
36
+ end
37
+
38
+ # Returns an integer to use as MongoDB key
39
+ def to_key
40
+ "#{to_i_timestamp}.#{to_i_hour}"
41
+ end
42
+
43
+ def self.from_key(ts, h)
44
+ Time.at(ts.to_i * ONEDAY + h.to_i * ONEHOUR)
45
+ end
46
+
47
+ # Returns a range to be enumerated using hours for the whole day
48
+ def whole_day
49
+ midnight = utc? ? Time.utc(year, month, day) : Time.new(year, month, day, 0, 0, 0, utc_offset)
50
+ midnight...(midnight + ::Range::DAYS)
51
+ end
52
+ end
@@ -0,0 +1,3 @@
1
+ # encoding: utf-8
2
+ require 'mongoid/tracking/core_ext/time'
3
+ require 'mongoid/tracking/core_ext/range'
@@ -0,0 +1,40 @@
1
+ # encoding: utf-8
2
+ module Mongoid #:nodoc
3
+ module Tracking #:nodoc
4
+ module Errors #:nodoc
5
+
6
+ class ClassAlreadyDefined < RuntimeError
7
+ def initialize(klass)
8
+ @klass = klass
9
+ end
10
+ def message
11
+ "#{@klass} already defined, can't aggregate!"
12
+ end
13
+ end
14
+
15
+ class AggregationAlreadyDefined < RuntimeError
16
+ def initialize(klass, token)
17
+ @klass = klass
18
+ @token = token
19
+ end
20
+ def message
21
+ "Aggregation '#{@token}' already defined for model #{@klass}"
22
+ end
23
+ end
24
+
25
+ class AggregationNameDeprecated < RuntimeError
26
+ def initialize(token)
27
+ @token = token
28
+ end
29
+ def message
30
+ "Ussing aggregation name '#{@klass}' is deprecated. Please select another name."
31
+ end
32
+ end
33
+
34
+ class ModelNotSaved < RuntimeError; end
35
+
36
+ class NotMongoid < RuntimeError; end
37
+
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,92 @@
1
+ # encoding: utf-8
2
+ module Mongoid #:nodoc:
3
+ module Tracking
4
+
5
+ # ReaderExtender is used in cases where we need to return an integer
6
+ # (class Numeric) while extending their contents. It would allow to
7
+ # perform advanced calculations in some situations:
8
+ #
9
+ # Example:
10
+ #
11
+ # a = visits.today # Would return a number, but "extended" so that
12
+ # # we can make a.hourly to get a detailed, hourly
13
+ # # array of the visits.
14
+ #
15
+ # b = visits.yesterday
16
+ # c = a + b # Here, in c, normally we would have a FixNum with
17
+ # # the sum of a plus b, but if we extend the sum
18
+ # # operation, we can additionaly sum the hourly
19
+ # # array and return a new ReaderExtender c.
20
+ #
21
+ class ReaderExtender
22
+ def initialize(number, hours)
23
+ @total = number
24
+ @hours = hours
25
+ end
26
+
27
+ def hourly
28
+ @hours
29
+ end
30
+
31
+ def to_s
32
+ @total.to_s
33
+ end
34
+
35
+ def to_f
36
+ @total.to_f
37
+ end
38
+
39
+ def to_i
40
+ @total.to_i
41
+ end
42
+
43
+ def ==(other)
44
+ @total == other
45
+ end
46
+
47
+ def <=>(other)
48
+ @total <=> other
49
+ end
50
+
51
+ def <(other)
52
+ @total < other
53
+ end
54
+ def <=(other)
55
+ @total <= other
56
+ end
57
+
58
+ def >(other)
59
+ @total > other
60
+ end
61
+
62
+ def >(other)
63
+ @total >= other
64
+ end
65
+
66
+ def +(other)
67
+ return @total + other unless other.is_a?(ReaderExtender)
68
+ self.class.new(other + @total, @hours.zip(other.hourly).map!(&:sum))
69
+ end
70
+
71
+ def coerce(other)
72
+ [self.to_i, other]
73
+ end
74
+
75
+ def as_json(options = nil)
76
+ @total
77
+ end
78
+
79
+ # Solution proposed by Yehuda Katz in the following Stack Overflow:
80
+ # http://stackoverflow.com/questions/1095789/sub-classing-fixnum-in-ruby
81
+ #
82
+ # Basically we override our methods while proxying all missing methods
83
+ # to the underliying FixNum
84
+ #
85
+ def method_missing(name, *args, &blk)
86
+ ret = @total.send(name, *args, &blk)
87
+ ret.is_a?(Numeric) ? ReaderExtender.new(ret, @hours) : ret
88
+ end
89
+ end
90
+
91
+ end
92
+ end
@@ -0,0 +1,85 @@
1
+ # encoding: utf-8
2
+ module Mongoid #:nodoc:
3
+ module Tracking
4
+ # Reader methods (previously known as "accessors")
5
+ module Readers
6
+
7
+
8
+
9
+ # Access methods
10
+ def today
11
+ whole_data_for(Time.now)
12
+ end
13
+
14
+ def yesterday
15
+ whole_data_for(Time.now - 1.day)
16
+ end
17
+
18
+ def first_value
19
+ data_for(first_date)
20
+ end
21
+
22
+ def last_value
23
+ data_for(last_date)
24
+ end
25
+
26
+ def last_days(how_much = 7)
27
+ return [today] unless how_much > 0
28
+ now, hmd = Time.now, (how_much - 1)
29
+ on( now.ago(hmd.days)..now )
30
+ end
31
+
32
+ def on(date)
33
+ if date.is_a?(Range)
34
+ whole_data_for_range(date)
35
+ else
36
+ whole_data_for(date)
37
+ end
38
+ end
39
+
40
+ def all_values
41
+ on(first_date..last_date) if first_date
42
+ end
43
+
44
+ def all_values_total
45
+ return all_values.sum.to_i if all_values && !all_values.nil?
46
+ return 0
47
+ end
48
+
49
+ # Utility methods
50
+ def first_date
51
+ date_cleanup
52
+ return nil unless _ts = @data.keys.min
53
+ return nil unless _h = @data[_ts].keys.min
54
+ Time.from_key(_ts, _h)
55
+ end
56
+
57
+ def last_date
58
+ date_cleanup
59
+ return nil unless _ts = @data.keys.max
60
+ return nil unless _h = @data[_ts].keys.max
61
+ Time.from_key(_ts, _h)
62
+ end
63
+
64
+ # We need the cleanup method only for methods who rely on date indexes
65
+ # to be valid (well formed) like first/last_date. This is because
66
+ # Mongo update operations cleans up the last key, which in our case
67
+ # left the array in an inconsistent state.
68
+ #
69
+ # Example:
70
+ # Before update:
71
+ #
72
+ # { :visits_data => {"14803" => {"22" => 1} } }
73
+ #
74
+ # After updating with: {"$unset"=>{"visits_data.14803.22"=>1}
75
+ #
76
+ # { :visits_data => {"14803" => {} } }
77
+ #
78
+ # We can NOT retrieve the first date with visits_data.keys.min
79
+ #
80
+ def date_cleanup
81
+ @data.reject! {|k,v| v.count == 0}
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,243 @@
1
+ # encoding: utf-8
2
+ module Mongoid #:nodoc:
3
+ module Tracking
4
+ # This internal class handles all interaction for a track field.
5
+ class Tracker
6
+
7
+
8
+ include Readers
9
+
10
+ def initialize(owner, field, aggregate_data)
11
+ @owner, @for = owner, field
12
+ @for_data = @owner.internal_track_name(@for)
13
+ @data = @owner.read_attribute(@for_data)
14
+
15
+ if @data.nil?
16
+ @owner.write_attribute(@for_data, {})
17
+ @data = @owner.read_attribute(@for_data)
18
+ end
19
+
20
+ @aggregate_data = aggregate_data.first if aggregate_data.first
21
+ end
22
+
23
+ # Delegate all missing methods to the aggregate accessors. This enables
24
+ # us to call an aggregation token after the tracking field.
25
+ #
26
+ # Example:
27
+ #
28
+ # <tt>@object.visits.browsers ...</tt>
29
+ #
30
+
31
+ def method_missing(name, *args, &block)
32
+ super unless @owner.aggregate_fields.member?(name)
33
+ @owner.send("#{name}_with_track".to_sym, @for, *args, &block)
34
+ end
35
+
36
+ # Update methods
37
+ def add(how_much = 1, date = Time.now)
38
+ raise Errors::ModelNotSaved, "Can't update a new record. Save first!" if @owner.new_record?
39
+ return if how_much == 0
40
+
41
+ # Note that the following #update_data method updates our local data
42
+ # and the current value might differ from the actual value on the
43
+ # database. Basically, what we do is update our own copy as a cache
44
+ # but send the command to atomically update the database: we don't
45
+ # read the actual value in return so that we save round trip delays.
46
+ #
47
+ update_data(data_for(date) + how_much, date)
48
+ @owner.inc(store_key(date) => how_much.abs)
49
+
50
+ return unless @owner.aggregated?
51
+
52
+ @owner.aggregate_fields.each do |k, v|
53
+ next unless token = v.call(@aggregate_data)
54
+ fk = @owner.class.name.to_s.foreign_key.to_sym
55
+ selector = { fk => @owner.id, ns: k, key: token.to_s }
56
+
57
+ docs = @owner.aggregate_klass.collection.find(selector)
58
+ docs.upsert("$inc" => update_hash(how_much.abs, date))
59
+ end
60
+ end
61
+
62
+ def inc(date = Time.now)
63
+ add(1, date)
64
+ end
65
+
66
+ def dec(date = Time.now)
67
+ add(-1, date)
68
+ end
69
+
70
+ def set(how_much, date = Time.now)
71
+ raise Errors::ModelNotSaved, "Can't update a new record" if @owner.new_record?
72
+ update_data(how_much, date)
73
+ #debugger
74
+ date = normalize_date(date)
75
+ if @owner.send(@for_data).empty?
76
+ @owner.set(@for_data => {date.to_key_timestamp => {date.to_key_hour => how_much}})
77
+ else
78
+ current_data = @owner.send(@for_data)
79
+ current_data.merge!({date.to_key_timestamp => {date.to_key_hour => how_much}})
80
+ @owner.set(@for_data => current_data)
81
+ end
82
+ return unless @owner.aggregated?
83
+
84
+ @owner.aggregate_fields.each do |(k,v)|
85
+ next unless token = v.call(@aggregate_data)
86
+ fk = @owner.class.name.to_s.foreign_key.to_sym
87
+ selector = { fk => @owner.id, ns: k, key: token.to_s }
88
+
89
+ docs = @owner.aggregate_klass.collection.find(selector)
90
+ docs.upsert("$set" => update_hash(how_much.abs, date))
91
+ end
92
+ end
93
+
94
+ def reset(how_much, date = Time.now)
95
+ return erase(date) if how_much.nil?
96
+
97
+ # First, we use the default "set" for the tracking field
98
+ # This will also update one aggregate but... oh well...
99
+ set(how_much, date)
100
+
101
+ # Need to iterate over all aggregates and send an update or delete
102
+ # operations over all mongo records for this aggregate field
103
+ @owner.aggregate_fields.each do |(k,v)|
104
+ fk = @owner.class.name.to_s.foreign_key.to_sym
105
+ selector = { fk => @owner.id, ns: k }
106
+ docs = @owner.aggregate_klass.collection.find(selector)
107
+ docs.update_all("$set" => update_hash(how_much.abs, date))
108
+ end
109
+ end
110
+
111
+ def erase(date = Time.now)
112
+ raise Errors::ModelNotSaved, "Can't update a new record" if @owner.new_record?
113
+
114
+ remove_data(date)
115
+
116
+ @owner.unset(store_key(date))
117
+
118
+ return unless @owner.aggregated?
119
+
120
+ # Need to iterate over all aggregates and send an update or delete
121
+ # operations over all mongo records
122
+ @owner.aggregate_fields.each do |(k,v)|
123
+ fk = @owner.class.name.to_s.foreign_key.to_sym
124
+ selector = { fk => @owner.id, ns: k }
125
+
126
+ docs = @owner.aggregate_klass.collection.find(selector)
127
+ docs.update_all("$unset" => update_hash(1, date))
128
+ end
129
+ end
130
+
131
+ private
132
+ def data_for(date)
133
+ unless date.nil?
134
+ date = normalize_date(date)
135
+ @data.try(:[], date.to_i_timestamp.to_s).try(:[], date.to_i_hour.to_s) || 0
136
+ end
137
+ end
138
+
139
+ def whole_data_for(date)
140
+ unless date.nil?
141
+ date = normalize_date(date)
142
+ if date.utc?
143
+ d = expand_hash @data[date.to_key_timestamp]
144
+ ReaderExtender.new(d.sum, d)
145
+ else
146
+ r = date.whole_day
147
+ d1 = expand_hash @data[r.first.to_key_timestamp]
148
+ d2 = expand_hash @data[r.last.to_key_timestamp]
149
+ t = d1[r.first.to_i_hour, 24] + d2[0, r.first.to_i_hour]
150
+ ReaderExtender.new(t.sum, t)
151
+ end
152
+ end
153
+ end
154
+
155
+ def whole_data_for_range(date)
156
+ date = normalize_date(date)
157
+ if date.first.utc?
158
+ keys = date.map(&:to_key_timestamp)
159
+ keys.inject([]) do |r, e|
160
+ d = expand_hash(@data[e])
161
+ r << ReaderExtender.new(d.sum, d)
162
+ end
163
+ else
164
+ first = date.first.whole_day.first.to_key_timestamp
165
+ last = date.last.whole_day.last.to_key_timestamp
166
+ pivot = date.first.whole_day.first.to_i_hour
167
+ acc = expand_hash(@data[first.to_s])
168
+
169
+ data = []
170
+ first.succ.upto(last) do |n|
171
+ d = expand_hash(@data[n])
172
+ t = acc[pivot, 24] + d[0, pivot]
173
+ acc = d
174
+ data << ReaderExtender.new(t.sum, t)
175
+ end
176
+ data
177
+ end
178
+ end
179
+
180
+ def expand_hash(h)
181
+ d = Array.new(24, 0)
182
+ h.inject(d) { |d, e| d[e.first.to_i] = e.last; d } if h
183
+ d
184
+ end
185
+
186
+ def update_data(value, date)
187
+ unless date.nil?
188
+ return remove_data(date) unless value
189
+ date = normalize_date(date)
190
+ dk, hk = date.to_i_timestamp.to_s, date.to_i_hour.to_s
191
+ unless ts = @data[dk]
192
+ ts = (@data[dk] = {})
193
+ end
194
+ ts[hk] = value
195
+ end
196
+ end
197
+
198
+ def remove_data(date)
199
+ unless date.nil?
200
+ date = normalize_date(date)
201
+ dk, hk = date.to_i_timestamp.to_s, date.to_i_hour.to_s
202
+ if ts = @data[dk]
203
+ ts.delete(hk)
204
+ unless ts.count > 0
205
+ @data.delete(dk)
206
+ end
207
+ end
208
+ end
209
+ end
210
+
211
+ # Returns a store key for passed date.
212
+ def store_key(date)
213
+ "#{@for_data}.#{normalize_date(date).to_key}"
214
+ end
215
+
216
+
217
+ def update_hash(num, date)
218
+ { store_key(date) => num }
219
+ end
220
+
221
+ # Allow for dates to be different types.
222
+ def normalize_date(date)
223
+ case date
224
+ when String
225
+ Time.parse(date)
226
+ when Date
227
+ date.to_time
228
+ when Range
229
+ normalize_date(date.first)..normalize_date(date.last)
230
+ else
231
+ date
232
+ end
233
+ end
234
+
235
+ # WARNING: This is +only+ for debugging purposes (rspec y tal)
236
+ def _original_hash
237
+ @data
238
+ end
239
+
240
+ end
241
+
242
+ end
243
+ end
@@ -0,0 +1,42 @@
1
+ # encoding: utf-8
2
+ module Mongoid #:nodoc:
3
+ module Tracking
4
+ # This internal class handles all interaction of an aggregation token.
5
+ class TrackerAggregates
6
+
7
+ def initialize(owner, token, key_selector, track_field = nil)
8
+ @owner, @token = owner, token
9
+ @key = key_selector.first
10
+ @track_field = track_field
11
+
12
+ @accessor = @owner.class.send(:internal_accessor_name, @token)
13
+ @selector = { ns: @token }
14
+ @selector.merge!(key: @key) if @key
15
+
16
+ @criteria = @owner.send(@accessor).where(@selector)
17
+ end
18
+
19
+ # Delegate all missing methods to the underlying Mongoid Criteria
20
+ def method_missing(name, *args, &block)
21
+ @criteria.send(name)
22
+ end
23
+
24
+ # Define all readers here. Basically we are delegating to the Track
25
+ # object for every object in the criteria
26
+ Readers.instance_methods.each {|name|
27
+ define_method(name) do |*args|
28
+ return nil unless @track_field
29
+ if @key
30
+ res = @criteria.first
31
+ res.send(@track_field).send(name, *args) if res
32
+ else
33
+ @criteria.collect {|c|
34
+ [c.key, c.send(@track_field).send(name, *args)]
35
+ }
36
+ end
37
+ end
38
+ }
39
+
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,112 @@
1
+ # encoding: utf-8
2
+ module Mongoid #:nodoc:
3
+ module Tracking #:nodoc:
4
+
5
+ # Include this module to add analytics tracking into a +root level+ document.
6
+ # Use "track :field" to add a field named :field and an associated mongoid
7
+ # field named after :field
8
+ def self.included(base)
9
+ base.class_eval do
10
+ unless self.ancestors.include? Mongoid::Document
11
+ raise Errors::NotMongoid, "Must be included in a Mongoid::Document"
12
+ end
13
+
14
+ include Aggregates
15
+ extend ClassMethods
16
+
17
+ class_attribute :tracked_fields
18
+ self.tracked_fields = []
19
+ delegate :tracked_fields, :internal_track_name, to: "self.class"
20
+ end
21
+ end
22
+
23
+ def clicks_score
24
+ (click_percent / 10.0).round
25
+ end
26
+
27
+ def click_percent
28
+ return 0 if impressions_count.eql?(0) || clicks.eql?(0)
29
+ if !impressions_count.eql?(0)
30
+ (( clicks.to_f / impressions_count.to_f) * 100).round
31
+ end
32
+ end
33
+
34
+ def avg_days
35
+ if publish_date < Date.today
36
+ if end_date > Date.today
37
+ days = (Date.today - publish_date).to_i
38
+ else
39
+ days = (end_date - publish_date).to_i
40
+ end
41
+ return days
42
+ else
43
+ return 0
44
+ end
45
+ end
46
+
47
+ def avg_daily_clicks
48
+ return (visits.all_values_total/ avg_days).to_f.round(2) if !avg_days.eql?(0)
49
+ return 0.0
50
+ end
51
+
52
+ def avg_daily_views
53
+ return (impressions.all_values_total / avg_days).to_f if !avg_days.eql?(0)
54
+ return 0.0
55
+ end
56
+
57
+ def impressions_count
58
+ return impressions.all_values_total
59
+ end
60
+
61
+
62
+ module ClassMethods
63
+ # Adds analytics tracking for +name+. Adds a +'name'_data+ mongoid
64
+ # field as a Hash for tracking this information. Additionaly, hiddes
65
+ # the field, so that the user can not mangle with the original one.
66
+ # This is necessary so that Mongoid does not "dirty" the field
67
+ # potentially overwriting the original data.
68
+ def track(name)
69
+ set_tracking_field(name.to_sym)
70
+ create_tracking_accessors(name.to_sym)
71
+ create_tracked_fields(name)
72
+ update_aggregates(name.to_sym) if aggregated?
73
+ end
74
+
75
+ def create_tracked_fields(name)
76
+ field "#{name}_data".to_sym, type: Hash, default: {}
77
+ end
78
+
79
+
80
+ # Returns the internal representation of the tracked field name
81
+ def internal_track_name(name)
82
+ "#{name}_data".to_sym
83
+ end
84
+
85
+ # Configures the internal fields for tracking. Additionally also creates
86
+ # an index for the internal tracking field.
87
+ def set_tracking_field(name)
88
+ # DONT make an index for this field. MongoDB indexes have limited
89
+ # size and seems that this is not a good target for indexing.
90
+ # index internal_track_name(name)
91
+ tracked_fields << name
92
+ end
93
+
94
+ # Creates the tracking field accessor and also disables the original
95
+ # ones from Mongoid. Hidding here the original accessors for the
96
+ # Mongoid fields ensures they doesn't get dirty, so Mongoid does not
97
+ # overwrite old data.
98
+ def create_tracking_accessors(name)
99
+ define_method(name) do |*aggr|
100
+ Tracker.new(self, name, aggr)
101
+ end
102
+ end
103
+
104
+ # Updates the aggregated class for it to include a new tracking field
105
+ def update_aggregates(name)
106
+ aggregate_klass.track name
107
+ end
108
+
109
+ end
110
+
111
+ end
112
+ end
@@ -0,0 +1,5 @@
1
+ # encoding: utf-8
2
+
3
+ module Trackoid #:nodoc:
4
+ VERSION = "0.1.3"
5
+ end
@@ -0,0 +1,12 @@
1
+ # encoding: utf-8
2
+
3
+ require 'trackoid_mongoid4/version'
4
+
5
+ require 'mongoid/tracking'
6
+ require 'mongoid/tracking/errors'
7
+ require 'mongoid/tracking/core_ext'
8
+ require 'mongoid/tracking/reader_extender'
9
+ require 'mongoid/tracking/readers'
10
+ require 'mongoid/tracking/tracker'
11
+ require 'mongoid/tracking/aggregates'
12
+ require 'mongoid/tracking/tracker_aggregates'