sir_tracks_alot 0.4.0 → 0.6.2
Sign up to get free protection for your applications and to get access to all the features.
- data/Gemfile +2 -1
- data/Gemfile.lock +54 -0
- data/README.rdoc +29 -1
- data/VERSION +1 -1
- data/benchmarks/activity_benchmark.rb +22 -23
- data/benchmarks/benchmark_helper.rb +30 -0
- data/benchmarks/count_benchmark.rb +33 -0
- data/lib/sir_tracks_alot.rb +16 -14
- data/lib/sir_tracks_alot/activity.rb +7 -6
- data/lib/sir_tracks_alot/clock.rb +4 -0
- data/lib/sir_tracks_alot/count.rb +87 -39
- data/lib/sir_tracks_alot/event_helper.rb +1 -1
- data/lib/sir_tracks_alot/filter_helper.rb +28 -18
- data/lib/sir_tracks_alot/persistable.rb +4 -0
- data/lib/sir_tracks_alot/queue/report_cache.rb +2 -0
- data/lib/sir_tracks_alot/queue/report_config.rb +3 -0
- data/lib/sir_tracks_alot/queue/report_queue.rb +8 -10
- data/lib/sir_tracks_alot/reports/actor_activity_report.rb +3 -3
- data/lib/sir_tracks_alot/reports/basic_report.rb +1 -1
- data/lib/sir_tracks_alot/reports/filter_report.rb +5 -11
- data/lib/sir_tracks_alot/reports/report.rb +16 -2
- data/lib/sir_tracks_alot/reports/simple_report.rb +43 -0
- data/lib/sir_tracks_alot/reports/target_report.rb +2 -7
- data/lib/sir_tracks_alot/summary.rb +9 -0
- data/sir_tracks_alot.gemspec +140 -0
- data/spec/activity_spec.rb +18 -59
- data/spec/count_spec.rb +56 -25
- data/spec/queue/report_queue_spec.rb +8 -8
- data/spec/redis_spec_helper.rb +8 -0
- data/spec/reports/actor_report_spec.rb +16 -16
- data/spec/reports/filter_report_spec.rb +1 -1
- data/spec/reports/report_spec.rb +15 -0
- data/spec/reports/root_stem_report_spec.rb +1 -1
- data/spec/reports/simple_report_spec.rb +63 -0
- data/spec/reports/target_report_spec.rb +9 -4
- data/spec/sir_tracks_alot_spec.rb +4 -0
- data/spec/spec_helper.rb +1 -9
- metadata +38 -10
data/Gemfile
CHANGED
data/Gemfile.lock
ADDED
@@ -0,0 +1,54 @@
|
|
1
|
+
GEM
|
2
|
+
remote: http://rubygems.org/
|
3
|
+
specs:
|
4
|
+
color (1.4.1)
|
5
|
+
crack (0.1.8)
|
6
|
+
fastercsv (1.5.3)
|
7
|
+
hashie (0.4.0)
|
8
|
+
hoe (2.6.2)
|
9
|
+
rake (>= 0.8.7)
|
10
|
+
rubyforge (>= 2.0.4)
|
11
|
+
hpricot (0.8.2)
|
12
|
+
httparty (0.6.1)
|
13
|
+
crack (= 0.1.8)
|
14
|
+
json_pure (1.4.6)
|
15
|
+
little-plugger (1.1.2)
|
16
|
+
logging (1.4.3)
|
17
|
+
little-plugger (>= 1.1.2)
|
18
|
+
multi_json (0.0.4)
|
19
|
+
oauth (0.4.3)
|
20
|
+
ohm (0.0.38)
|
21
|
+
redis (>= 1.0.4)
|
22
|
+
pdf-writer (1.1.8)
|
23
|
+
color (>= 1.4.0)
|
24
|
+
transaction-simple (~> 1.3)
|
25
|
+
rake (0.8.7)
|
26
|
+
redis (2.0.10)
|
27
|
+
rspec (1.3.0)
|
28
|
+
rspec_hpricot_matchers (1.0)
|
29
|
+
rubyforge (2.0.4)
|
30
|
+
json_pure (>= 1.1.7)
|
31
|
+
ruport (1.6.3)
|
32
|
+
fastercsv
|
33
|
+
pdf-writer (= 1.1.8)
|
34
|
+
transaction-simple (1.4.0)
|
35
|
+
hoe (>= 1.1.7)
|
36
|
+
twitter (0.9.12)
|
37
|
+
hashie (~> 0.4.0)
|
38
|
+
httparty (~> 0.6.1)
|
39
|
+
multi_json (~> 0.0.4)
|
40
|
+
oauth (~> 0.4.3)
|
41
|
+
|
42
|
+
PLATFORMS
|
43
|
+
ruby
|
44
|
+
|
45
|
+
DEPENDENCIES
|
46
|
+
color
|
47
|
+
hpricot
|
48
|
+
logging
|
49
|
+
ohm (= 0.0.38)
|
50
|
+
redis
|
51
|
+
rspec
|
52
|
+
rspec_hpricot_matchers
|
53
|
+
ruport
|
54
|
+
twitter
|
data/README.rdoc
CHANGED
@@ -1,6 +1,34 @@
|
|
1
1
|
= sir_tracks_alot
|
2
2
|
|
3
|
-
|
3
|
+
Sir Tracks Alot is a ruby library designed to aid in the tracking, collating and analysis of arbitrary usage data. You can use it to record anything! Then summarize what's been recorded into useful graphs. The primary use case for Sir Tracks Alot was to instrument web usage of Rails applications.
|
4
|
+
|
5
|
+
For example, to that a specific user has viewed a specific page:
|
6
|
+
|
7
|
+
SirTracksAlot.record_activity :owner => 'owner', :actor => '/users/123', :target => '/pages/1'
|
8
|
+
|
9
|
+
To include information taken from a web request (such as the user agent), simple include it:
|
10
|
+
|
11
|
+
SirTracksAlot.record_activity :owner => 'owner', :actor => '/users/123', :target => '/pages/1', :request => request
|
12
|
+
|
13
|
+
You can also include the notion of action type:
|
14
|
+
|
15
|
+
SirTracksAlot.record_activity :owner => 'owner', :actor => '/users/123', :target => '/pages/1', :action => 'create'
|
16
|
+
|
17
|
+
To investigate what's been stored you can filter activities with a variety of method and any attribute can be queried.
|
18
|
+
|
19
|
+
Direct queries work on strings. For example, to search for all activities by a certain owner:
|
20
|
+
|
21
|
+
Activity.filter(:owner => 'owner')
|
22
|
+
|
23
|
+
Regular expressions can be used:
|
24
|
+
|
25
|
+
Activity.filter(:owner => 'owner', :target => /\/pages\/\d+/)
|
26
|
+
|
27
|
+
You can also combine permutations of various options. For example, to find all 'create' and 'view' actions for both '/pages' and '/profiles' targets:
|
28
|
+
|
29
|
+
Activity.filter(:owner => 'owner', :target => ['/pages', '/profiles'], :actions => ['create', 'destroy'])
|
30
|
+
|
31
|
+
|
4
32
|
|
5
33
|
== Note on Patches/Pull Requests
|
6
34
|
|
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
0.
|
1
|
+
0.6.2
|
@@ -1,35 +1,34 @@
|
|
1
|
-
require '
|
2
|
-
require 'ohm'
|
3
|
-
require 'lib/trackable'
|
1
|
+
require 'benchmarks/benchmark_helper'
|
4
2
|
|
5
|
-
|
3
|
+
@owner = 'owner'
|
6
4
|
|
7
|
-
|
5
|
+
RedisSpecHelper.reset
|
6
|
+
DataBuilder.build(1000)
|
8
7
|
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
8
|
+
Benchmark.bm do |b|
|
9
|
+
b.report('filtering by owner') do
|
10
|
+
SirTracksAlot::Activity.filter(:owner => @owner)
|
11
|
+
end
|
13
12
|
|
14
|
-
|
15
|
-
|
16
|
-
b.report('grabbing all activities, and iterating') do
|
17
|
-
Trackable::Activity.filter(:owner => @owner).collect{|a| a.target}
|
13
|
+
b.report('filtering by target with regex (all match)') do
|
14
|
+
SirTracksAlot::Activity.filter(:owner => @owner, :target => /.+/)
|
18
15
|
end
|
19
16
|
|
20
|
-
b.report('
|
21
|
-
|
17
|
+
b.report('filtering by target with regex (no matches)') do
|
18
|
+
SirTracksAlot::Activity.filter(:owner => @owner, :target => /xxxxxxx/)
|
19
|
+
end
|
20
|
+
|
21
|
+
b.report('filtering by owner, and iterating') do
|
22
|
+
SirTracksAlot::Activity.filter(:owner => @owner).collect{|a| a.target}
|
22
23
|
end
|
23
24
|
|
24
|
-
b.report('
|
25
|
-
|
25
|
+
b.report('filtering and retrieve last_event') do
|
26
|
+
SirTracksAlot::Activity.filter(:owner => @owner).map{|a| a.last_event}
|
26
27
|
end
|
27
28
|
|
28
|
-
b.report('
|
29
|
-
|
29
|
+
b.report('filtering and retrieve all events') do
|
30
|
+
SirTracksAlot::Activity.filter(:owner => @owner).map{|a| a.events.map{|e| e}}
|
30
31
|
end
|
31
|
-
|
32
|
-
b.report('count_by daily') do
|
33
|
-
# counts = Trackable::Activity.count_by(:daily, :owner => @owner)
|
34
|
-
end
|
35
32
|
end
|
33
|
+
|
34
|
+
RedisSpecHelper.reset
|
@@ -0,0 +1,30 @@
|
|
1
|
+
require "rubygems"
|
2
|
+
require "bundler"
|
3
|
+
Bundler.setup(:development)
|
4
|
+
Bundler.require(:development)
|
5
|
+
require 'lib/sir_tracks_alot'
|
6
|
+
require 'benchmark'
|
7
|
+
require 'spec/redis_spec_helper'
|
8
|
+
|
9
|
+
Ohm.connect(:host => '127.0.0.1', :timeout => 2, :db => 15)
|
10
|
+
|
11
|
+
class DataBuilder
|
12
|
+
START_DATE = 1272559940
|
13
|
+
ONE_HOUR = 3600
|
14
|
+
|
15
|
+
def self.build(count = 1000, owner = 'owner', ref_count = 100)
|
16
|
+
puts "** Building activities..."
|
17
|
+
|
18
|
+
count.times do |i|
|
19
|
+
event = START_DATE + (ONE_HOUR * i)
|
20
|
+
target = "/targets/#{rand ref_count}"
|
21
|
+
actor = "/actors/#{rand ref_count}"
|
22
|
+
user_agent = "user_agents-#{rand ref_count}"
|
23
|
+
view = 'view'
|
24
|
+
|
25
|
+
SirTracksAlot.record(:owner => owner, :event => event, :target => target, :actor => actor, :user_agent => user_agent, :action => view)
|
26
|
+
end
|
27
|
+
|
28
|
+
puts "** Done building #{count} activities."
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
require 'benchmarks/benchmark_helper'
|
2
|
+
|
3
|
+
@owner = 'owner'
|
4
|
+
RedisSpecHelper.reset
|
5
|
+
DataBuilder.build(1000)
|
6
|
+
|
7
|
+
@results = {}
|
8
|
+
|
9
|
+
Benchmark.bm do |b|
|
10
|
+
b.report('counting activities') do
|
11
|
+
SirTracksAlot::Count.count({:owner => @owner}, {:limit => 999999}, :daily)
|
12
|
+
end
|
13
|
+
|
14
|
+
b.report('building rows for reports') do
|
15
|
+
SirTracksAlot::Count.rows(:owner => @owner)
|
16
|
+
end
|
17
|
+
|
18
|
+
b.report('filtering counts by owner') do
|
19
|
+
SirTracksAlot::Count.find(:owner => @owner)
|
20
|
+
end
|
21
|
+
|
22
|
+
b.report('filtering counts with regex') do
|
23
|
+
SirTracksAlot::Count.rows(:owner => @owner, :target => /.+/)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
counts_count = SirTracksAlot::Count.all.size
|
28
|
+
summaries_count = SirTracksAlot::Summary.all.size
|
29
|
+
activities_count = SirTracksAlot::Activity.all.size
|
30
|
+
|
31
|
+
puts "*** Generated #{activities_count}, #{counts_count} counts (#{(counts_count+0.1)/(activities_count+0.1)}), #{summaries_count} summaries"
|
32
|
+
|
33
|
+
RedisSpecHelper.reset
|
data/lib/sir_tracks_alot.rb
CHANGED
@@ -1,16 +1,18 @@
|
|
1
1
|
$:.unshift(File.dirname(__FILE__)) unless
|
2
2
|
$:.include?(File.dirname(__FILE__)) || $:.include?(File.expand_path(File.dirname(__FILE__)))
|
3
3
|
|
4
|
-
|
5
|
-
require "
|
6
|
-
|
7
|
-
Bundler.
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
4
|
+
begin
|
5
|
+
require "rubygems"
|
6
|
+
require "bundler"
|
7
|
+
Bundler.setup
|
8
|
+
Bundler.require
|
9
|
+
rescue LoadError
|
10
|
+
require 'logging'
|
11
|
+
require 'twitter'
|
12
|
+
require 'ruport'
|
13
|
+
require 'ohm'
|
14
|
+
require 'redis'
|
15
|
+
end
|
14
16
|
|
15
17
|
module SirTracksAlot
|
16
18
|
TRACKABLE_ROOT = "#{File.dirname(__FILE__)}/.."
|
@@ -20,6 +22,7 @@ module SirTracksAlot
|
|
20
22
|
autoload :EventHelper, File.join(File.dirname(__FILE__), *%w[sir_tracks_alot event_helper.rb])
|
21
23
|
autoload :FilterHelper, File.join(File.dirname(__FILE__), *%w[sir_tracks_alot filter_helper.rb])
|
22
24
|
autoload :Count, File.join(File.dirname(__FILE__), *%w[sir_tracks_alot count.rb])
|
25
|
+
autoload :Summary, File.join(File.dirname(__FILE__), *%w[sir_tracks_alot summary.rb])
|
23
26
|
autoload :Clock, File.join(File.dirname(__FILE__), *%w[sir_tracks_alot clock.rb])
|
24
27
|
|
25
28
|
class SirTracksAlotError < StandardError
|
@@ -57,9 +60,7 @@ module SirTracksAlot
|
|
57
60
|
|
58
61
|
raise RecordInvalidError.new("Activity not valid: #{activity.errors.inspect}") unless activity.valid?
|
59
62
|
|
60
|
-
|
61
|
-
|
62
|
-
activity.update(:last_event => event)
|
63
|
+
activity.update(:last_event => event, :counted => '0')
|
63
64
|
activity.events << event
|
64
65
|
activity
|
65
66
|
end
|
@@ -94,7 +95,7 @@ raise "EVENT NIL" if event.nil?
|
|
94
95
|
return @log if @log
|
95
96
|
|
96
97
|
@log = Logging::Logger[self]
|
97
|
-
@log.level =
|
98
|
+
@log.level = ENV['SIR_TRACKS_ALOT_LOG_LEVEL'] || 'warn'
|
98
99
|
@log
|
99
100
|
end
|
100
101
|
|
@@ -121,6 +122,7 @@ raise "EVENT NIL" if event.nil?
|
|
121
122
|
|
122
123
|
module Reports
|
123
124
|
autoload :Report, File.join(File.dirname(__FILE__), *%w[sir_tracks_alot reports report.rb])
|
125
|
+
autoload :SimpleReport, File.join(File.dirname(__FILE__), *%w[sir_tracks_alot reports simple_report.rb])
|
124
126
|
autoload :BasicReport, File.join(File.dirname(__FILE__), *%w[sir_tracks_alot reports basic_report.rb])
|
125
127
|
autoload :SirTracksAlotReport, File.join(File.dirname(__FILE__), *%w[sir_tracks_alot reports trackable_report.rb])
|
126
128
|
autoload :TargetReport, File.join(File.dirname(__FILE__), *%w[sir_tracks_alot reports target_report.rb])
|
@@ -4,7 +4,6 @@ module SirTracksAlot
|
|
4
4
|
extend FilterHelper
|
5
5
|
|
6
6
|
ACTIONS = [:create, :view, :login, :search, :update, :destroy]
|
7
|
-
LIMIT = 500
|
8
7
|
|
9
8
|
attribute :created_at
|
10
9
|
attribute :last_event # Clock.now
|
@@ -15,7 +14,7 @@ module SirTracksAlot
|
|
15
14
|
attribute :action # create, view, login, etc.
|
16
15
|
attribute :user_agent # IE/Safari Windows/Mac etc.
|
17
16
|
attribute :counted # true/false
|
18
|
-
list :events
|
17
|
+
list :events # Clock.now's
|
19
18
|
|
20
19
|
index :owner
|
21
20
|
index :actor
|
@@ -36,19 +35,21 @@ module SirTracksAlot
|
|
36
35
|
end
|
37
36
|
|
38
37
|
# find recent activities
|
39
|
-
def self.recent(options_for_find, options_for_sort = {:order => 'DESC', :limit =>
|
38
|
+
def self.recent(options_for_find, options_for_sort = {:order => 'DESC', :limit => 500})
|
40
39
|
find(options_for_find).sort_by(:last_event, options_for_sort)
|
41
40
|
end
|
42
41
|
|
43
|
-
# Delete counted activities, leaving a default of the 500 most recent for the provided search criteria
|
44
|
-
|
45
|
-
def self.purge!(options_for_find = {}, options_for_sort = {:order => 'DESC'})
|
42
|
+
# Delete 500 counted activities, leaving a default of the 500 most recent for the provided search criteria
|
43
|
+
def self.purge!(options_for_find = {}, options_for_sort = {:order => 'DESC', :start => 500, :limit => 500})
|
46
44
|
recent(options_for_find.merge(:counted => 1), options_for_sort).each{|a| a.delete}
|
47
45
|
end
|
48
46
|
|
49
47
|
# set this activity to counted
|
48
|
+
# and clear the recorded events
|
50
49
|
def counted!
|
51
50
|
update(:counted => '1')
|
51
|
+
events.clear
|
52
|
+
true
|
52
53
|
end
|
53
54
|
|
54
55
|
# is this activity counted?
|
@@ -5,75 +5,123 @@ module SirTracksAlot
|
|
5
5
|
ACTIONS = [:create, :view, :login, :search, :update, :destroy]
|
6
6
|
|
7
7
|
attribute :created_at
|
8
|
-
attribute :date # 10/01/2010
|
9
|
-
attribute :hour # 1 - 23
|
10
8
|
attribute :owner # 123123
|
11
9
|
attribute :actor # /users/peter-brown
|
12
10
|
attribute :target # /discussions/23423
|
13
|
-
attribute :category
|
14
|
-
attribute :
|
15
|
-
|
11
|
+
attribute :category # /discussions
|
12
|
+
attribute :action # create
|
13
|
+
set :summaries, Summary
|
16
14
|
|
17
|
-
index :date
|
18
|
-
index :hour
|
19
15
|
index :owner
|
20
16
|
index :actor
|
21
17
|
index :target
|
22
18
|
index :category
|
19
|
+
index :action
|
23
20
|
|
24
|
-
def self.
|
25
|
-
|
21
|
+
def self.total(what, options_for_find)
|
22
|
+
what = [what] unless what.kind_of?(Array)
|
23
|
+
views, visits = 0, 0
|
24
|
+
|
25
|
+
filter(options_for_find).each do |count|
|
26
|
+
count.summaries.each do |summary|
|
27
|
+
views += summary.views.to_i if what.include?(:views)
|
28
|
+
visits += summary.visits.to_i if what.include?(:visits)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
return [views, visits] if what == [:views, :visits]
|
33
|
+
return [visits, views] if what == [:visits, :views]
|
34
|
+
return views if what == [:views]
|
35
|
+
return visits if what == [:visits]
|
36
|
+
raise ArgumentError("what must be one or both of :views, :visits")
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.rows(options_for_find, title = nil)
|
40
|
+
groups = {}
|
26
41
|
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
42
|
+
filter(options_for_find).each do |count|
|
43
|
+
t = title || count.target || count.actor # don't currently support counts with both actor and target set
|
44
|
+
|
45
|
+
count.summaries.each do |summary|
|
46
|
+
groups[t] ||= [0,0]
|
47
|
+
groups[t][0] += summary.views.to_i
|
48
|
+
groups[t][1] += summary.visits.to_i
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
groups.collect{|g| g.flatten}
|
53
|
+
end
|
54
|
+
|
55
|
+
# Convert a default of 500 activities into counts
|
56
|
+
def self.count(options_for_find = {}, options_for_sort = {:limit => 500}, resolution = :hourly)
|
57
|
+
options_for_find = options_for_find.to_hash
|
58
|
+
session_duration = options_for_find.delete(:session_duration)
|
59
|
+
activities = Activity.find(options_for_find.merge(:counted => 0, :action => [], :category => [])).sort(options_for_sort)
|
60
|
+
rollup(activities, session_duration, resolution)
|
34
61
|
end
|
35
62
|
|
36
|
-
|
63
|
+
# Summarize counts daily or hourly
|
64
|
+
# All times are in UTC
|
65
|
+
def self.summarize(resolution = :daily, options_for_find = {})
|
66
|
+
counts = {}
|
67
|
+
|
68
|
+
filter(options_for_find).each do |count|
|
69
|
+
count.summaries.each do |summary|
|
70
|
+
key = case resolution
|
71
|
+
when :daily
|
72
|
+
Time.at(summary.date.to_i).utc.strftime('%Y/%m/%d')
|
73
|
+
when :hourly
|
74
|
+
Time.at(summary.date.to_i).utc.strftime('%Y/%m/%d %H:00 UTC')
|
75
|
+
end
|
76
|
+
|
77
|
+
counts[key] ||= [0,0]
|
78
|
+
counts[key][0] += summary.views.to_i
|
79
|
+
counts[key][1] += summary.visits.to_i
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
counts
|
84
|
+
end
|
85
|
+
|
86
|
+
# Rollup all activities by resolution
|
87
|
+
# Create one count for actor and one count for target
|
88
|
+
def self.rollup(activities, session_duration, resolution = :hourly)
|
37
89
|
counts = []
|
38
90
|
|
39
91
|
activities.each do |activity|
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
92
|
+
count = Count.find_or_create(:owner => activity.owner, :category => activity.category, :target => activity.target, :action => activity.action)
|
93
|
+
|
94
|
+
activity.views(resolution).each do |time, views|
|
95
|
+
date = Time.parse(time).to_i
|
96
|
+
summary = count.summaries.find(:date => date).first || Summary.create(:date => date)
|
97
|
+
count.summaries << summary
|
98
|
+
summary.update(:views => (summary.views||0).to_i + views.to_i)
|
44
99
|
end
|
45
100
|
|
46
|
-
activity.visits(session_duration,
|
47
|
-
date
|
48
|
-
|
49
|
-
|
101
|
+
activity.visits(session_duration, resolution).each do |time, visits|
|
102
|
+
date = Time.parse(time).to_i
|
103
|
+
summary = count.summaries.find(:date => date).first || Summary.create(:date => date)
|
104
|
+
count.summaries << summary
|
105
|
+
summary.update(:visits => (summary.visits||0).to_i + visits.to_i)
|
50
106
|
end
|
51
107
|
|
52
108
|
activity.counted!
|
109
|
+
counts << count
|
53
110
|
end
|
54
111
|
|
55
112
|
counts
|
56
113
|
end
|
57
114
|
|
58
|
-
def
|
59
|
-
|
60
|
-
|
61
|
-
|
115
|
+
def to_hash
|
116
|
+
{:owner => owner, :actor => actor, :target => target, :category => category, :id => id}
|
117
|
+
end
|
118
|
+
|
62
119
|
|
63
|
-
count.views = count.views.to_i + views
|
64
|
-
count.visits = count.visits.to_i + visits
|
65
|
-
|
66
|
-
count.save
|
67
|
-
end
|
68
|
-
|
69
|
-
|
70
120
|
private
|
71
121
|
|
72
122
|
def validate
|
73
123
|
assert_present :owner
|
74
|
-
|
75
|
-
assert_present :visits
|
76
|
-
assert_unique([:owner, :actor, :target, :category, :date, :hour])
|
124
|
+
assert_unique([:owner, :actor, :target, :category, :action])
|
77
125
|
end
|
78
126
|
|
79
127
|
end
|