tracks_visits 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/MIT-LICENSE +20 -0
- data/README.textile +145 -0
- data/Rakefile +51 -0
- data/lib/tracks_visits.rb +16 -0
- data/lib/tracks_visits/tracks_visits_error.rb +11 -0
- data/lib/tracks_visits/visit.rb +8 -0
- data/lib/tracks_visits/visitable.rb +284 -0
- data/lib/tracks_visits/visitor.rb +40 -0
- data/rails/init.rb +1 -0
- data/test/test_helper.rb +52 -0
- data/test/tracks_visits_test.rb +108 -0
- metadata +76 -0
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2009 Jonas Grimfelt
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.textile
ADDED
@@ -0,0 +1,145 @@
|
|
1
|
+
h1. TRACKS_VISITS
|
2
|
+
|
3
|
+
_Rails: Track unique and total visits/viewings of an ActiveRecord resource based on user/account or IP._
|
4
|
+
|
5
|
+
h2. Installation
|
6
|
+
|
7
|
+
<pre>sudo gem install grimen-tracks_visits</pre>
|
8
|
+
|
9
|
+
h2. Usage
|
10
|
+
|
11
|
+
1. Generate migration:
|
12
|
+
|
13
|
+
<pre>
|
14
|
+
$ ./script/generate tracks_visits_migration
|
15
|
+
</pre>
|
16
|
+
|
17
|
+
Generates @db/migrations/{timestamp}_tracks_visits_migration@ with:
|
18
|
+
|
19
|
+
<pre>
|
20
|
+
class TracksVisitsMigration < ActiveRecord::Migration
|
21
|
+
def self.up
|
22
|
+
create_table :visits do |t|
|
23
|
+
t.references :visitable, :polymorphic => true
|
24
|
+
|
25
|
+
t.references :visitor, :polymorphic => true
|
26
|
+
t.string :ip, :limit => 24
|
27
|
+
|
28
|
+
t.integer :visits, :default => 0
|
29
|
+
|
30
|
+
# created_at <=> first_visited_at
|
31
|
+
# updated_at <=> last_visited_at
|
32
|
+
t.timestamps
|
33
|
+
end
|
34
|
+
|
35
|
+
add_index :visits, [:visitor_id, :visitor_type]
|
36
|
+
add_index :visits, [:visitable_id, :visitable_type]
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.down
|
40
|
+
drop_table :visits
|
41
|
+
end
|
42
|
+
end
|
43
|
+
</pre>
|
44
|
+
|
45
|
+
2. Make your model count visits:
|
46
|
+
|
47
|
+
<pre>
|
48
|
+
class Post < ActiveRecord::Base
|
49
|
+
tracks_visits
|
50
|
+
end
|
51
|
+
</pre>
|
52
|
+
|
53
|
+
or
|
54
|
+
|
55
|
+
<pre>
|
56
|
+
class Post < ActiveRecord::Base
|
57
|
+
tracks_visits :by => :user # Setup associations for the visitor class automatically
|
58
|
+
end
|
59
|
+
</pre>
|
60
|
+
|
61
|
+
*Note:* @:by@ is optional if you choose any of @User@ or @Account@ as visitor classes.
|
62
|
+
|
63
|
+
3. ...and here we go:
|
64
|
+
|
65
|
+
<pre>
|
66
|
+
@post = Post.create
|
67
|
+
|
68
|
+
@post.visited? # => false
|
69
|
+
@post.unique_visits # => 0
|
70
|
+
@post.total_visits # => 0
|
71
|
+
|
72
|
+
@post.visit!(:ip => '128.0.0.0')
|
73
|
+
@post.visit!(:visitor => @user) # aliases: :user, :account
|
74
|
+
|
75
|
+
@post.visited? # => true
|
76
|
+
@post.unique_visits # => 2
|
77
|
+
@post.total_visits # => 2
|
78
|
+
|
79
|
+
@post.visit!(:ip => '128.0.0.0')
|
80
|
+
@post.visit!(:visitor => @user)
|
81
|
+
@post.visit!(:ip => '128.0.0.1')
|
82
|
+
|
83
|
+
@post.unique_visits # => 3
|
84
|
+
@post.total_visits # => 5
|
85
|
+
|
86
|
+
@post.visted_by?('128.0.0.0') # => true
|
87
|
+
@post.visted_by?(@user) # => true
|
88
|
+
@post.visted_by?('128.0.0.2') # => false
|
89
|
+
@post.visted_by?(@another_user) # => false
|
90
|
+
|
91
|
+
@post.reset_visits!
|
92
|
+
@post.unique_visits # => 0
|
93
|
+
@post.total_visits # => 0
|
94
|
+
|
95
|
+
# Note: See documentation for more info.
|
96
|
+
|
97
|
+
</pre>
|
98
|
+
|
99
|
+
h2. Caching
|
100
|
+
|
101
|
+
If the visitable class table - in the sample above @Post@ - contains a columns @total_visits_count@ and @unique_visits_count@, then a cached value will be maintained within it for the number of unique and total visits the object have got. This will save a database query for counting the number of visits, which is a common task.
|
102
|
+
|
103
|
+
Additional caching fields:
|
104
|
+
|
105
|
+
<pre>
|
106
|
+
class AddTrackVisitsCachingToPostsMigration < ActiveRecord::Migration
|
107
|
+
def self.up
|
108
|
+
# Enable tracks_visits-caching.
|
109
|
+
add_column :posts, :unique_visits_count, :integer
|
110
|
+
add_column :posts, :total_visits_count, :integer
|
111
|
+
end
|
112
|
+
|
113
|
+
def self.down
|
114
|
+
remove_column :posts, :unique_visits_count
|
115
|
+
remove_column :posts, :total_visits_count
|
116
|
+
end
|
117
|
+
end
|
118
|
+
</pre>
|
119
|
+
|
120
|
+
h2. Dependencies
|
121
|
+
|
122
|
+
Basic usage:
|
123
|
+
|
124
|
+
* "rails":http://github.com/rails/rails (well...)
|
125
|
+
|
126
|
+
For running tests:
|
127
|
+
|
128
|
+
* sqlite3-ruby
|
129
|
+
* "thoughtbot-shoulda":http://github.com/thoughtbot/shoulda
|
130
|
+
* "nakajima-acts_as_fu":http://github.com/nakajima/acts_as_fu
|
131
|
+
* "jgre-monkeyspecdoc":http://github.com/jgre/monkeyspecdoc
|
132
|
+
|
133
|
+
h2. Notes
|
134
|
+
|
135
|
+
* Tested with Ruby 1.9+. =)
|
136
|
+
* Let me know if you find any bugs; not used in production yet so consider this a concept version.
|
137
|
+
|
138
|
+
h2. TODO
|
139
|
+
|
140
|
+
* More thorough tests for more complex scenarios.
|
141
|
+
* ActiveModel finder helpers.
|
142
|
+
|
143
|
+
h2. License
|
144
|
+
|
145
|
+
Copyright (c) Jonas Grimfelt, released under the MIT-license.
|
data/Rakefile
ADDED
@@ -0,0 +1,51 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
require 'rubygems'
|
3
|
+
require 'rake'
|
4
|
+
require 'rake/testtask'
|
5
|
+
require 'rake/rdoctask'
|
6
|
+
|
7
|
+
NAME = "tracks_visits"
|
8
|
+
SUMMARY = %Q{Rails: Track unique and total visits/viewings of an ActiveRecord resource based on user/account or IP.}
|
9
|
+
HOMEPAGE = "http://github.com/grimen/#{NAME}/tree/master"
|
10
|
+
AUTHOR = "Jonas Grimfelt"
|
11
|
+
EMAIL = "grimen@gmail.com"
|
12
|
+
SUPPORT_FILES = %w(README.textile)
|
13
|
+
|
14
|
+
begin
|
15
|
+
gem 'technicalpickles-jeweler', '>= 1.2.1'
|
16
|
+
require 'jeweler'
|
17
|
+
Jeweler::Tasks.new do |gem|
|
18
|
+
gem.name = NAME
|
19
|
+
gem.summary = SUMMARY
|
20
|
+
gem.description = SUMMARY
|
21
|
+
gem.homepage = HOMEPAGE
|
22
|
+
gem.author = AUTHOR
|
23
|
+
gem.email = EMAIL
|
24
|
+
|
25
|
+
gem.require_paths = %w{lib}
|
26
|
+
gem.files = SUPPORT_FILES << %w(MIT-LICENSE Rakefile) << Dir.glob(File.join('{lib,test,rails}', '**', '*'))
|
27
|
+
gem.executables = %w()
|
28
|
+
gem.extra_rdoc_files = SUPPORT_FILES
|
29
|
+
end
|
30
|
+
rescue LoadError
|
31
|
+
puts "Jeweler, or one of its dependencies, is not available. Install it with: sudo gem install technicalpickles-jeweler -s http://gems.github.com"
|
32
|
+
end
|
33
|
+
|
34
|
+
desc %Q{Run unit tests for "#{NAME}".}
|
35
|
+
task :default => :test
|
36
|
+
|
37
|
+
desc %Q{Run unit tests for "#{NAME}".}
|
38
|
+
Rake::TestTask.new(:test) do |test|
|
39
|
+
test.libs << %w(lib test)
|
40
|
+
test.pattern = File.join('test', '**', '*_test.rb')
|
41
|
+
test.verbose = true
|
42
|
+
end
|
43
|
+
|
44
|
+
desc %Q{Generate documentation for "#{NAME}".}
|
45
|
+
Rake::RDocTask.new(:rdoc) do |rdoc|
|
46
|
+
rdoc.rdoc_dir = 'rdoc'
|
47
|
+
rdoc.title = NAME
|
48
|
+
rdoc.options << '--line-numbers' << '--inline-source' << '--charset=UTF-8'
|
49
|
+
rdoc.rdoc_files.include(SUPPORT_FILES)
|
50
|
+
rdoc.rdoc_files.include(File.join('lib', '**', '*.rb'))
|
51
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
require File.join(File.dirname(__FILE__), 'tracks_visits', 'tracks_visits_error')
|
3
|
+
require File.join(File.dirname(__FILE__), 'tracks_visits', 'visit')
|
4
|
+
require File.join(File.dirname(__FILE__), 'tracks_visits', 'visitor')
|
5
|
+
require File.join(File.dirname(__FILE__), 'tracks_visits', 'visitable')
|
6
|
+
|
7
|
+
module TracksVisits
|
8
|
+
|
9
|
+
def log(message, level = :info)
|
10
|
+
level = :info if level.blank?
|
11
|
+
RAILS_DEFAULT_LOGGER.send level.to_sym, message
|
12
|
+
end
|
13
|
+
|
14
|
+
extend self
|
15
|
+
|
16
|
+
end
|
@@ -0,0 +1,284 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
require File.join(File.dirname(__FILE__), 'tracks_visits_error')
|
3
|
+
require File.join(File.dirname(__FILE__), 'visit')
|
4
|
+
require File.join(File.dirname(__FILE__), 'visitor')
|
5
|
+
|
6
|
+
module TracksVisits #:nodoc:
|
7
|
+
module ActiveRecord #:nodoc:
|
8
|
+
module Visitable
|
9
|
+
|
10
|
+
DEFAULT_VISIT_CLASS_NAME = :visit.freeze
|
11
|
+
|
12
|
+
def self.included(base) #:nodoc:
|
13
|
+
base.class_eval do
|
14
|
+
extend ClassMethods
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
module ClassMethods
|
19
|
+
|
20
|
+
# Make the model visitable, i.e. count/track visits by user/account of IP.
|
21
|
+
#
|
22
|
+
# * Adds a <tt>has_many :visits</tt> association to the model for easy retrieval of the detailed visits.
|
23
|
+
# * Adds a <tt>has_many :visitors</tt> association to the object.
|
24
|
+
# * Adds a <tt>has_many :visits</tt> associations to the visitor class.
|
25
|
+
#
|
26
|
+
# === Options
|
27
|
+
# * <tt>:options[:visit_class]</tt> - class of the model used for the visits. Defaults to Visit.
|
28
|
+
# This class will be dynamically created if not already defined. If the class is predefined,
|
29
|
+
# it must have in it the following definitions:
|
30
|
+
# <tt>belongs_to :visitable, :polymorphic => true</tt>
|
31
|
+
# <tt>belongs_to :visitor, :class_name => 'User', :foreign_key => :visitor_id</tt> replace user with
|
32
|
+
# the visitor class if needed.
|
33
|
+
# * <tt>:options[:visitor_class]</tt> - class of the model that creates the visit.
|
34
|
+
# Defaults to User or Account - auto-detected. This class will NOT be created, so it must be defined in the app.
|
35
|
+
# Use the IP address to prevent multiple visits from the same client.
|
36
|
+
#
|
37
|
+
def tracks_visits(options = {})
|
38
|
+
send :include, ::TracksVisits::ActiveRecord::Visitable::InstanceMethods
|
39
|
+
|
40
|
+
# Set default class names if not given.
|
41
|
+
options[:visitor_class_name] ||= options[:from] || Visitor::DEFAULT_CLASS_NAME
|
42
|
+
options[:visitor_class_name] = options[:visitor_class_name].to_s.classify
|
43
|
+
|
44
|
+
options[:visit_class_name] = DEFAULT_VISIT_CLASS_NAME.to_s.classify
|
45
|
+
|
46
|
+
options[:visitor_class] = options[:visitor_class_name].constantize rescue nil
|
47
|
+
|
48
|
+
# Assocations: Visit class (e.g. Visit).
|
49
|
+
options[:visit_class] = begin
|
50
|
+
options[:visit_class_name].constantize
|
51
|
+
rescue
|
52
|
+
# If note defined...define it!
|
53
|
+
Object.const_set(options[:visit_class_name].to_sym, Class.new(::ActiveRecord::Base)).class_eval do
|
54
|
+
belongs_to :visitable, :polymorphic => true
|
55
|
+
belongs_to :visitor, :polymorphic => true
|
56
|
+
end
|
57
|
+
options[:visit_class_name].constantize
|
58
|
+
end
|
59
|
+
|
60
|
+
# Save the initialized options for this class.
|
61
|
+
write_inheritable_attribute(:tracks_visits_options, options.slice(:visit_class, :visitor_class))
|
62
|
+
class_inheritable_reader :tracks_visits_options
|
63
|
+
|
64
|
+
# Assocations: Visitor class (e.g. User).
|
65
|
+
if Object.const_defined?(options[:visitor_class].name.to_sym)
|
66
|
+
options[:visitor_class].class_eval do
|
67
|
+
has_many :visits,
|
68
|
+
:foreign_key => :visitor_id,
|
69
|
+
:class_name => options[:visit_class].name
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
# Assocations: Visitable class (e.g. Page).
|
74
|
+
self.class_eval do
|
75
|
+
has_many options[:visit_class].name.tableize.to_sym,
|
76
|
+
:as => :visitable,
|
77
|
+
:dependent => :delete_all,
|
78
|
+
:class_name => options[:visit_class].name
|
79
|
+
|
80
|
+
has_many options[:visitor_class].name.tableize.to_sym,
|
81
|
+
:through => options[:visit_class].name.tableize,
|
82
|
+
:class_name => options[:visitor_class].name
|
83
|
+
|
84
|
+
# Hooks.
|
85
|
+
before_create :init_has_visits_fields
|
86
|
+
end
|
87
|
+
|
88
|
+
end
|
89
|
+
|
90
|
+
# Does this class count/track visits?
|
91
|
+
#
|
92
|
+
def visitable?
|
93
|
+
self.respond_do?(:tracks_visits_options)
|
94
|
+
end
|
95
|
+
alias :is_visitable? :visitable?
|
96
|
+
|
97
|
+
protected
|
98
|
+
|
99
|
+
def validate_visitor(identifiers)
|
100
|
+
raise TracksVisitsError, "Not initilized correctly" unless defined?(:tracks_visits_options)
|
101
|
+
raise TracksVisitsError, "Argument can't be nil: no IP and/or user provided" if identifiers.blank?
|
102
|
+
|
103
|
+
visitor = identifiers[:visitor] || identifiers[:user] || identifiers[:account]
|
104
|
+
ip = identifiers[:ip]
|
105
|
+
|
106
|
+
#tracks_visits_options[:visitor_class].present?
|
107
|
+
# raise TracksVisitsError, "Visitor is of wrong type: #{visitor.class}" unless (visitor.nil? || visitor.is_a?(tracks_visits_options[:visitor_class]))
|
108
|
+
#end
|
109
|
+
raise TracksVisitsError, "IP is of wrong type: #{ip.class}" unless (ip.nil? || ip.is_a?(String))
|
110
|
+
raise TracksVisitsError, "Arguments not supported: no ip and/or user provided" unless ((visitor && visitor.id) || ip)
|
111
|
+
|
112
|
+
[visitor, ip]
|
113
|
+
end
|
114
|
+
|
115
|
+
end
|
116
|
+
|
117
|
+
module InstanceMethods
|
118
|
+
|
119
|
+
# Does this object count/track visits?
|
120
|
+
#
|
121
|
+
def visitable?
|
122
|
+
self.class.visitable?
|
123
|
+
end
|
124
|
+
alias :is_visitable? :visitable?
|
125
|
+
|
126
|
+
# first_visit = created_at.
|
127
|
+
#
|
128
|
+
def first_visited_at
|
129
|
+
self.created_at if self.respond_to?(:created_at)
|
130
|
+
end
|
131
|
+
|
132
|
+
# last_visit = updated_at.
|
133
|
+
#
|
134
|
+
def last_visited_at
|
135
|
+
self.updated_at if self.respond_to?(:updated_at)
|
136
|
+
end
|
137
|
+
|
138
|
+
# Get the unique number of visits for this object based on the visits field,
|
139
|
+
# or with a SQL query if the visited objects doesn't have the visits field
|
140
|
+
#
|
141
|
+
def unique_visits
|
142
|
+
if self.has_cached_fields?
|
143
|
+
self.unique_visits || 0
|
144
|
+
else
|
145
|
+
self.visits.size
|
146
|
+
end
|
147
|
+
end
|
148
|
+
alias :number_of_visitors :unique_visits
|
149
|
+
|
150
|
+
# Get the total number of visits for this object.
|
151
|
+
#
|
152
|
+
def total_visits
|
153
|
+
if self.has_cached_fields?
|
154
|
+
self.total_visits || 0
|
155
|
+
else
|
156
|
+
tracks_visits_options[:visit_class].sum(:visits, :conditions => {:visitable_id => self.id})
|
157
|
+
end
|
158
|
+
end
|
159
|
+
alias :number_of_visits :total_visits
|
160
|
+
|
161
|
+
# Is this object visited by anyone?
|
162
|
+
#
|
163
|
+
def visited?
|
164
|
+
self.unique_visits > 0
|
165
|
+
end
|
166
|
+
alias :is_visited? :visited?
|
167
|
+
|
168
|
+
# Check if an item was already visited by the given visitor or ip.
|
169
|
+
#
|
170
|
+
# === Identifiers hash:
|
171
|
+
# * <tt>:ip</tt> - identify with IP
|
172
|
+
# * <tt>:visitor</tt> - identify with a visitor-model (e.g. User, ...)
|
173
|
+
# * <tt>:user</tt> - (same as above)
|
174
|
+
# * <tt>:account</tt> - (same as above)
|
175
|
+
#
|
176
|
+
def visited_by?(identifiers)
|
177
|
+
visitor, ip = self.validate_visitor(identifiers)
|
178
|
+
|
179
|
+
conditions = if visitor.present?
|
180
|
+
{:visitor => visitor}
|
181
|
+
else # ip
|
182
|
+
{:ip => (ip ? ip.to_s.strip : nil)}
|
183
|
+
end
|
184
|
+
self.visits.count(:conditions => conditions) > 0
|
185
|
+
end
|
186
|
+
alias :is_visited_by? :visited_by?
|
187
|
+
|
188
|
+
# Delete all tracked visits for this visitable object.
|
189
|
+
#
|
190
|
+
def reset_visits!
|
191
|
+
self.visits.delete_all
|
192
|
+
self.total_visits_count = self.unique_visits_count = 0 if self.has_cached_fields?
|
193
|
+
end
|
194
|
+
|
195
|
+
# View the object with and identifier (user or ip) - create new if new visitor.
|
196
|
+
#
|
197
|
+
# === Identifiers hash:
|
198
|
+
# * <tt>:ip</tt> - identify with IP
|
199
|
+
# * <tt>:visitor</tt> - identify with a visitor-model (e.g. User, ...)
|
200
|
+
# * <tt>:user</tt> - (same as above)
|
201
|
+
# * <tt>:account</tt> - (same as above)
|
202
|
+
#
|
203
|
+
def visit!(identifiers)
|
204
|
+
visitor, ip = self.validate_visitor(identifiers)
|
205
|
+
|
206
|
+
begin
|
207
|
+
# Count unique visits only, based on account or IP.
|
208
|
+
visit = self.visits.find_by_visitor_id(visitor.id) if visitor.present?
|
209
|
+
visit ||= self.visits.find_by_ip(ip) if ip.present?
|
210
|
+
|
211
|
+
# Try to get existing visit for the current visitor,
|
212
|
+
# or create a new otherwise and set new attributes.
|
213
|
+
if visit.present?
|
214
|
+
visit.visits += 1
|
215
|
+
|
216
|
+
unique_visit = false
|
217
|
+
else
|
218
|
+
visit = tracks_visits_options[:visit_class].new do |v|
|
219
|
+
#v.visitor = visitor
|
220
|
+
#v.visitable = self
|
221
|
+
v.visitable_id = self.id
|
222
|
+
v.visitable_type = self.class.name
|
223
|
+
v.visitor_id = visitor.id if visitor
|
224
|
+
v.visitor_type = visitor.class.name if visitor
|
225
|
+
v.ip = ip if ip.present?
|
226
|
+
v.visits = 1
|
227
|
+
end
|
228
|
+
self.visits << visit
|
229
|
+
|
230
|
+
unique_visit = true
|
231
|
+
end
|
232
|
+
|
233
|
+
visit.save
|
234
|
+
|
235
|
+
# Maintain cached value if cached field is available.
|
236
|
+
#
|
237
|
+
if self.has_cached_fields?
|
238
|
+
self.unique_visits += 1 if unique_visit
|
239
|
+
self.total_visits += 1
|
240
|
+
self.save_without_validation
|
241
|
+
end
|
242
|
+
|
243
|
+
true
|
244
|
+
rescue Exception => e
|
245
|
+
raise TracksVisitsError, "Database transaction failed: #{e}"
|
246
|
+
false
|
247
|
+
end
|
248
|
+
end
|
249
|
+
|
250
|
+
protected
|
251
|
+
|
252
|
+
# Is there a cached fields for this visitable class?
|
253
|
+
#
|
254
|
+
def cached_fields?
|
255
|
+
self.attributes.has_key?(:total_visits_count) && self.attributes.has_key?(:unique_visits_count)
|
256
|
+
end
|
257
|
+
alias :has_cached_fields? :cached_fields?
|
258
|
+
|
259
|
+
# Initialize cached fields - if any.
|
260
|
+
#
|
261
|
+
def init_has_visits_fields
|
262
|
+
self.total_visits = self.unique_visits = 0 if self.has_cached_fields?
|
263
|
+
end
|
264
|
+
|
265
|
+
def validate_visitor(identifiers)
|
266
|
+
self.class.send :validate_visitor, identifiers
|
267
|
+
end
|
268
|
+
|
269
|
+
end
|
270
|
+
|
271
|
+
module SingletonMethods
|
272
|
+
|
273
|
+
# TODO: Finders
|
274
|
+
|
275
|
+
end
|
276
|
+
|
277
|
+
end
|
278
|
+
end
|
279
|
+
end
|
280
|
+
|
281
|
+
# Extend ActiveRecord.
|
282
|
+
::ActiveRecord::Base.class_eval do
|
283
|
+
include ::TracksVisits::ActiveRecord::Visitable
|
284
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
require File.join(File.dirname(__FILE__), 'tracks_visits_error')
|
3
|
+
|
4
|
+
module TracksVisits #:nodoc:
|
5
|
+
module ActiveRecord #:nodoc:
|
6
|
+
module Visitor
|
7
|
+
|
8
|
+
DEFAULT_CLASS_NAME = begin
|
9
|
+
if defined?(Account)
|
10
|
+
:account
|
11
|
+
else
|
12
|
+
:user
|
13
|
+
end
|
14
|
+
rescue
|
15
|
+
:user
|
16
|
+
end
|
17
|
+
DEFAULT_CLASS_NAME.freeze
|
18
|
+
|
19
|
+
def self.included(base) #:nodoc:
|
20
|
+
base.class_eval do
|
21
|
+
include InstanceMethods
|
22
|
+
extend ClassMethods
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
module ClassMethods
|
27
|
+
|
28
|
+
# Nothing
|
29
|
+
|
30
|
+
end
|
31
|
+
|
32
|
+
module InstanceMethods
|
33
|
+
|
34
|
+
# Nothing
|
35
|
+
|
36
|
+
end
|
37
|
+
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
data/rails/init.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require File.expand_path(File.join(File.dirname(__FILE__), '..', 'lib', 'tracks_visits'))
|
data/test/test_helper.rb
ADDED
@@ -0,0 +1,52 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
require 'rubygems'
|
3
|
+
|
4
|
+
gem 'test-unit', '>= 2.0.0'
|
5
|
+
gem 'thoughtbot-shoulda', '>= 2.0.0'
|
6
|
+
gem 'sqlite3-ruby', '>= 1.2.0'
|
7
|
+
gem 'nakajima-acts_as_fu', '>= 0.0.5'
|
8
|
+
gem 'jgre-monkeyspecdoc', '>= 0.9.5'
|
9
|
+
|
10
|
+
require 'test/unit'
|
11
|
+
require 'shoulda'
|
12
|
+
require 'acts_as_fu'
|
13
|
+
require 'monkeyspecdoc'
|
14
|
+
|
15
|
+
require 'test_helper'
|
16
|
+
|
17
|
+
require 'tracks_visits'
|
18
|
+
|
19
|
+
# To get ZenTest to get it.
|
20
|
+
# require File.expand_path(File.join(File.dirname(__FILE__), 'tracks_visits_test.rb'))
|
21
|
+
|
22
|
+
build_model :visits do
|
23
|
+
references :visitable, :polymorphic => true
|
24
|
+
|
25
|
+
references :visitor, :polymorphic => true
|
26
|
+
string :ip, :limit => 24
|
27
|
+
|
28
|
+
integer :visits, :default => 0
|
29
|
+
|
30
|
+
timestamps
|
31
|
+
end
|
32
|
+
|
33
|
+
build_model :guests do
|
34
|
+
end
|
35
|
+
|
36
|
+
build_model :users do
|
37
|
+
string :username
|
38
|
+
end
|
39
|
+
|
40
|
+
build_model :untracked_posts do
|
41
|
+
end
|
42
|
+
|
43
|
+
build_model :tracked_posts do
|
44
|
+
tracks_visits :from => :users
|
45
|
+
end
|
46
|
+
|
47
|
+
build_model :cached_tracked_posts do
|
48
|
+
integer :unique_visits_count
|
49
|
+
integer :total_visits_count
|
50
|
+
|
51
|
+
tracks_visits :from => :users
|
52
|
+
end
|
@@ -0,0 +1,108 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
require 'test_helper'
|
3
|
+
|
4
|
+
class TracksVisitsTest < Test::Unit::TestCase
|
5
|
+
|
6
|
+
def setup
|
7
|
+
@user_1 = ::User.create
|
8
|
+
@user_2 = ::User.create
|
9
|
+
@tracked_post = ::TrackedPost.create
|
10
|
+
@regular_post = ::TrackedPost.create
|
11
|
+
end
|
12
|
+
|
13
|
+
context "initialization" do
|
14
|
+
|
15
|
+
should "extend ActiveRecord::Base" do
|
16
|
+
assert_respond_to ::ActiveRecord::Base, :tracks_visits
|
17
|
+
end
|
18
|
+
|
19
|
+
should "declare tracks_visits instance methods for visitable objects" do
|
20
|
+
methods = [
|
21
|
+
:first_visited_at,
|
22
|
+
:first_visited_at,
|
23
|
+
:last_visited_at,
|
24
|
+
:unique_visits,
|
25
|
+
:total_visits,
|
26
|
+
:visited_by?,
|
27
|
+
:reset_visits!,
|
28
|
+
:visitable?,
|
29
|
+
:visit!
|
30
|
+
]
|
31
|
+
|
32
|
+
assert methods.all? { |m| @tracked_post.respond_to?(m) }
|
33
|
+
# assert !methods.any? { |m| @tracked_post.respond_to?(m) }
|
34
|
+
end
|
35
|
+
|
36
|
+
# Don't work for some reason... =S
|
37
|
+
# should "be enabled only for specified models" do
|
38
|
+
# assert @tracked_post.visitable?
|
39
|
+
# assert_not @untracked_post.visitable?
|
40
|
+
# end
|
41
|
+
|
42
|
+
end
|
43
|
+
|
44
|
+
context "visitable" do
|
45
|
+
should "have zero visits from the beginning" do
|
46
|
+
assert_equal(@tracked_post.visits.size, 0)
|
47
|
+
end
|
48
|
+
|
49
|
+
should "count visits based on IP correctly" do
|
50
|
+
number_of_unique_visits = @tracked_post.unique_visits
|
51
|
+
number_of_total_visits = @tracked_post.total_visits
|
52
|
+
|
53
|
+
@tracked_post.visit!(:ip => '128.0.0.0')
|
54
|
+
@tracked_post.visit!(:ip => '128.0.0.1')
|
55
|
+
@tracked_post.visit!(:ip => '128.0.0.1')
|
56
|
+
|
57
|
+
assert_equal @tracked_post.unique_visits, number_of_unique_visits + 2
|
58
|
+
assert_equal @tracked_post.total_visits, number_of_total_visits + 3
|
59
|
+
end
|
60
|
+
|
61
|
+
should "count visits based on visitor object (user/account) correctly" do
|
62
|
+
number_of_unique_visits = @tracked_post.unique_visits
|
63
|
+
number_of_total_visits = @tracked_post.total_visits
|
64
|
+
|
65
|
+
@tracked_post.visit!(:user => @user_1)
|
66
|
+
@tracked_post.visit!(:user => @user_2)
|
67
|
+
@tracked_post.visit!(:user => @user_2)
|
68
|
+
|
69
|
+
assert_equal @tracked_post.unique_visits, number_of_unique_visits + 2
|
70
|
+
assert_equal @tracked_post.total_visits, number_of_total_visits + 3
|
71
|
+
end
|
72
|
+
|
73
|
+
should "count visits based on both IP and visitor object (user/account) correctly" do
|
74
|
+
number_of_unique_visits = @tracked_post.unique_visits
|
75
|
+
number_of_total_visits = @tracked_post.total_visits
|
76
|
+
|
77
|
+
@tracked_post.visit!(:ip => '128.0.0.0')
|
78
|
+
@tracked_post.visit!(:ip => '128.0.0.0')
|
79
|
+
@tracked_post.visit!(:user => @user_1)
|
80
|
+
@tracked_post.visit!(:user => @user_2)
|
81
|
+
@tracked_post.visit!(:user => @user_2)
|
82
|
+
|
83
|
+
assert_equal @tracked_post.unique_visits, number_of_unique_visits + 3
|
84
|
+
assert_equal @tracked_post.total_visits, number_of_total_visits + 5
|
85
|
+
end
|
86
|
+
|
87
|
+
should "delete all visits upon reset" do
|
88
|
+
@tracked_post.visit!(:ip => '128.0.0.0')
|
89
|
+
@tracked_post.reset_visits!
|
90
|
+
|
91
|
+
assert_equal @tracked_post.unique_visits, 0
|
92
|
+
assert_equal @tracked_post.total_visits, 0
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
context "visitor" do
|
97
|
+
|
98
|
+
# Nothing
|
99
|
+
|
100
|
+
end
|
101
|
+
|
102
|
+
context "visit" do
|
103
|
+
|
104
|
+
# Nothing
|
105
|
+
|
106
|
+
end
|
107
|
+
|
108
|
+
end
|
metadata
ADDED
@@ -0,0 +1,76 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: tracks_visits
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- grimen@gmail.com
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
|
12
|
+
date: 2009-08-31 00:00:00 +02:00
|
13
|
+
default_executable:
|
14
|
+
dependencies: []
|
15
|
+
|
16
|
+
description: "Rails: Track unique and total visits/viewings of an ActiveRecord resource based on user/account or IP."
|
17
|
+
email: Jonas Grimfelt
|
18
|
+
executables: []
|
19
|
+
|
20
|
+
extensions: []
|
21
|
+
|
22
|
+
extra_rdoc_files:
|
23
|
+
- MIT-LICENSE
|
24
|
+
- README.textile
|
25
|
+
- Rakefile
|
26
|
+
- lib/tracks_visits.rb
|
27
|
+
- lib/tracks_visits/tracks_visits_error.rb
|
28
|
+
- lib/tracks_visits/visit.rb
|
29
|
+
- lib/tracks_visits/visitable.rb
|
30
|
+
- lib/tracks_visits/visitor.rb
|
31
|
+
- rails/init.rb
|
32
|
+
- test/test_helper.rb
|
33
|
+
- test/tracks_visits_test.rb
|
34
|
+
files:
|
35
|
+
- MIT-LICENSE
|
36
|
+
- README.textile
|
37
|
+
- Rakefile
|
38
|
+
- lib/tracks_visits.rb
|
39
|
+
- lib/tracks_visits/tracks_visits_error.rb
|
40
|
+
- lib/tracks_visits/visit.rb
|
41
|
+
- lib/tracks_visits/visitable.rb
|
42
|
+
- lib/tracks_visits/visitor.rb
|
43
|
+
- rails/init.rb
|
44
|
+
- test/test_helper.rb
|
45
|
+
- test/tracks_visits_test.rb
|
46
|
+
has_rdoc: true
|
47
|
+
homepage: http://github.com/grimen/tracks_visits/tree/master
|
48
|
+
licenses: []
|
49
|
+
|
50
|
+
post_install_message:
|
51
|
+
rdoc_options:
|
52
|
+
- --charset=UTF-8
|
53
|
+
require_paths:
|
54
|
+
- lib
|
55
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
56
|
+
requirements:
|
57
|
+
- - ">="
|
58
|
+
- !ruby/object:Gem::Version
|
59
|
+
version: "0"
|
60
|
+
version:
|
61
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
62
|
+
requirements:
|
63
|
+
- - ">="
|
64
|
+
- !ruby/object:Gem::Version
|
65
|
+
version: "0"
|
66
|
+
version:
|
67
|
+
requirements: []
|
68
|
+
|
69
|
+
rubyforge_project:
|
70
|
+
rubygems_version: 1.3.5
|
71
|
+
signing_key:
|
72
|
+
specification_version: 3
|
73
|
+
summary: "Rails: Track unique and total visits/viewings of an ActiveRecord resource based on user/account or IP."
|
74
|
+
test_files:
|
75
|
+
- test/test_helper.rb
|
76
|
+
- test/tracks_visits_test.rb
|