tracks_visits 0.1.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.
- 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
|