is_visitable 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 +266 -0
- data/Rakefile +63 -0
- data/generators/is_visitable_migration/is_visitable_migration_generator.rb +12 -0
- data/generators/is_visitable_migration/templates/migration.rb +25 -0
- data/lib/is_visitable.rb +38 -0
- data/lib/is_visitable/support.rb +49 -0
- data/lib/is_visitable/visit.rb +49 -0
- data/lib/is_visitable/visitable.rb +354 -0
- data/lib/is_visitable/visitor.rb +7 -0
- data/test/is_visitable_test.rb +109 -0
- data/test/test_helper.rb +57 -0
- metadata +147 -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,266 @@
|
|
1
|
+
h1. IS_VISITABLE
|
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
|
+
*Gem:*
|
8
|
+
|
9
|
+
<pre>sudo gem install is_visitable</pre>
|
10
|
+
|
11
|
+
and in @config/environment.rb@:
|
12
|
+
|
13
|
+
<pre>config.gem 'is_visitable'</pre>
|
14
|
+
|
15
|
+
*Plugin:*
|
16
|
+
|
17
|
+
<pre>./script/plugin install git://github.com/grimen/is_visitable.git</pre>
|
18
|
+
|
19
|
+
h2. Usage
|
20
|
+
|
21
|
+
h3. 1. Generate migration:
|
22
|
+
|
23
|
+
<pre>
|
24
|
+
$ ./script/generate is_visitable_migration
|
25
|
+
</pre>
|
26
|
+
|
27
|
+
Generates @db/migrations/{timestamp}_is_visitable_migration@ with:
|
28
|
+
|
29
|
+
<pre>
|
30
|
+
class IsVisitableMigration < ActiveRecord::Migration
|
31
|
+
def self.up
|
32
|
+
create_table :visits do |t|
|
33
|
+
t.references :visitable, :polymorphic => true
|
34
|
+
|
35
|
+
t.references :visitor, :polymorphic => true
|
36
|
+
t.string :ip, :limit => 24
|
37
|
+
|
38
|
+
t.integer :visits, :default => 1
|
39
|
+
|
40
|
+
# created_at <=> first_visited_at
|
41
|
+
# updated_at <=> last_visited_at
|
42
|
+
t.timestamps
|
43
|
+
end
|
44
|
+
|
45
|
+
add_index :visits, [:visitor_id, :visitor_type]
|
46
|
+
add_index :visits, [:visitable_id, :visitable_type]
|
47
|
+
end
|
48
|
+
|
49
|
+
def self.down
|
50
|
+
drop_table :visits
|
51
|
+
end
|
52
|
+
end
|
53
|
+
</pre>
|
54
|
+
|
55
|
+
h3. 2. Make your model count visits:
|
56
|
+
|
57
|
+
<pre>
|
58
|
+
class Post < ActiveRecord::Base
|
59
|
+
is_visitable
|
60
|
+
end
|
61
|
+
</pre>
|
62
|
+
|
63
|
+
or, with explicit visitor (or visitors):
|
64
|
+
|
65
|
+
<pre>
|
66
|
+
class Post < ActiveRecord::Base
|
67
|
+
# Setup associations for the visitor class(es) automatically.
|
68
|
+
is_visitable :by => [:users, :ducks]
|
69
|
+
end
|
70
|
+
</pre>
|
71
|
+
|
72
|
+
h3. 3. ...and here we go:
|
73
|
+
|
74
|
+
Examples:
|
75
|
+
|
76
|
+
<pre>
|
77
|
+
@post = Post.create
|
78
|
+
|
79
|
+
@post.visited? # => false
|
80
|
+
@post.unique_visits # => 0
|
81
|
+
@post.total_visits # => 0
|
82
|
+
|
83
|
+
@post.visit!(:visitor => '128.0.0.0')
|
84
|
+
@post.visit!(:visitor => @user) # aliases: :user, :account
|
85
|
+
|
86
|
+
@post.visited? # => true
|
87
|
+
@post.unique_visits # => 2
|
88
|
+
@post.total_visits # => 2
|
89
|
+
|
90
|
+
@post.visit!(:visitor => '128.0.0.0')
|
91
|
+
@post.visit!(:visitor => @user)
|
92
|
+
@post.visit!(:visitor => '128.0.0.1')
|
93
|
+
|
94
|
+
@post.unique_visits # => 3
|
95
|
+
@post.total_visits # => 5
|
96
|
+
|
97
|
+
@post.visited_by?('128.0.0.0') # => true
|
98
|
+
@post.visited_by?(@user) # => true
|
99
|
+
@post.visited_by?('128.0.0.2') # => false
|
100
|
+
@post.visited_by?(@another_user) # => false
|
101
|
+
|
102
|
+
@post.reset_visits!
|
103
|
+
@post.unique_visits # => 0
|
104
|
+
@post.total_visits # => 0
|
105
|
+
|
106
|
+
# Note: See documentation for more info.
|
107
|
+
|
108
|
+
</pre>
|
109
|
+
|
110
|
+
h2. Mixin Arguments
|
111
|
+
|
112
|
+
The @is_visitable@ mixin takes some hash arguments for customization:
|
113
|
+
|
114
|
+
* @:by@ - the visitor model(s), e.g. User, Account, etc. (accepts either symbol or class, i.e. @User@ <=> @:user@ <=> @:users@, or an array of suchif there are more than one visitor model). The visitor model will be setup for you. Note: Polymorhic, so it accepts any model. Default: @nil@.
|
115
|
+
* @:accept_ip@ - accept anonymous users uniquely identified by IP (well...you handle the bots =D). See examples below how to use this as your visitor object. Default: @false@.
|
116
|
+
|
117
|
+
h2. Aliases
|
118
|
+
|
119
|
+
To make the usage of IsVistable a bit more generic (similar to other plugins you may use), there are two useful aliases for this purpose:
|
120
|
+
|
121
|
+
* @Visit#owner@ <=> @Visit#visitor@
|
122
|
+
* @Visit#object@ <=> @Visit#visitable@
|
123
|
+
|
124
|
+
Example:
|
125
|
+
|
126
|
+
<pre>
|
127
|
+
@post.visits.first.owner == post.visits.first.visitor # => true
|
128
|
+
@post.visits.first.object == post.visits.first.visitable # => true
|
129
|
+
</pre>
|
130
|
+
|
131
|
+
h2. Finders (Named Scopes)
|
132
|
+
|
133
|
+
IsVisitable has plenty of useful finders implemented using named scopes. Here they are:
|
134
|
+
|
135
|
+
h3. @Visit@
|
136
|
+
|
137
|
+
*Order:*
|
138
|
+
|
139
|
+
* @in_order@ - most recent visits last (order by creation date).
|
140
|
+
* @most_recent@ - most recent visits first (opposite of @in_order@ above).
|
141
|
+
* @least_visits@ - visits with least total visits first.
|
142
|
+
* @most_rating@ - visits with most total visits first.
|
143
|
+
|
144
|
+
*Filter:*
|
145
|
+
|
146
|
+
* @limit(<number_of_items>)@ - maximum @<number_of_items>@ visits.
|
147
|
+
* @since(<created_at_datetime>)@ - visits since @<created_at_datetime>@.
|
148
|
+
* @recent(<datetime_or_size>)@ - if DateTime: visits since @<datetime_or_size>@, else if Fixnum: pick last @<datetime_or_size>@ number of visits.
|
149
|
+
* @between_dates(<from_date>, to_date)@ - visits between two datetimes.
|
150
|
+
* @with_visits(<visits_value_or_range>)@ - visits with(in) visits value (or range) @<visits_value_or_range>@.
|
151
|
+
* @of_visitable_type(<visitable_type>)@ - visits of @<visitable_type>@ type of visitable models.
|
152
|
+
* @by_visitor_type(<visitor_type>)@ - visits of @<visitor_type>@ type of visitor models.
|
153
|
+
* @on(<visitable_object>)@ - visits on the visitable object @<visitable_object>@ .
|
154
|
+
* @by(<visitor_object>)@ - visits by the @<visitor_object>@ type of visitor models.
|
155
|
+
|
156
|
+
h3. @Visitable@
|
157
|
+
|
158
|
+
_TODO: Documentation on named scopes for Visitable._
|
159
|
+
|
160
|
+
h3. @Visitor@
|
161
|
+
|
162
|
+
_TODO: Documentation on named scopes for Visitor._
|
163
|
+
|
164
|
+
h3. Examples using finders:
|
165
|
+
|
166
|
+
<pre>
|
167
|
+
@user = User.first
|
168
|
+
@post = Post.first
|
169
|
+
|
170
|
+
@post.visits.recent(10) # => [10 most recent visits]
|
171
|
+
@post.visits.recent(1.week.ago) # => [visits since 1 week ago]
|
172
|
+
|
173
|
+
@post.visits.with_visits(100..500) # => [all visits on @post with total visits between 100 and 500]
|
174
|
+
|
175
|
+
@post.visits.by_visitor_type(:user) # => [all visits on @post by User-objects]
|
176
|
+
# ...or:
|
177
|
+
@post.visits.by_visitor_type(:users) # => [all visits on @post by User-objects]
|
178
|
+
# ...or:
|
179
|
+
@post.visits.by_visitor_type(User) # => [all visits on @post by User-objects]
|
180
|
+
|
181
|
+
@user.visits.on(@post) # => [all visits by @user on @post]
|
182
|
+
@post.visits.by(@user) # => [all visits by @user on @post] (equivalent with above)
|
183
|
+
|
184
|
+
Visit.on(@post) # => [all visits on @user] <=> @post.visits
|
185
|
+
Visit.by(@user) # => [all visits by @user] <=> @user.visits
|
186
|
+
|
187
|
+
# etc, etc. It's all named scopes, so it's really no new hokus-pokus you have to learn.
|
188
|
+
</pre>
|
189
|
+
|
190
|
+
h2. Additional Methods
|
191
|
+
|
192
|
+
*Note:* See documentation (RDoc).
|
193
|
+
|
194
|
+
h2. Extend the Visit model
|
195
|
+
|
196
|
+
This is optional, but if you wanna be in control of your models (in this case @Visit@) you can take control like this:
|
197
|
+
|
198
|
+
<pre>
|
199
|
+
class Visit < IsVisitable::Visit
|
200
|
+
|
201
|
+
# Do what you do best here... (stating the obvious: core IsVisitable associations, named scopes, etc. will be inherited)
|
202
|
+
|
203
|
+
end
|
204
|
+
</pre>
|
205
|
+
|
206
|
+
h2. Caching
|
207
|
+
|
208
|
+
If the visitable class table - in the sample above @Post@ - contains a columns @cached_total_visits_count@ and @cached_unique_visits@, 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.
|
209
|
+
|
210
|
+
Additional caching fields:
|
211
|
+
|
212
|
+
<pre>
|
213
|
+
class AddTrackVisitsCachingToPostsMigration < ActiveRecord::Migration
|
214
|
+
def self.up
|
215
|
+
# Enable is_visitable-caching.
|
216
|
+
add_column :posts, :cached_unique_visits, :integer
|
217
|
+
add_column :posts, :cached_total_visits, :integer
|
218
|
+
end
|
219
|
+
|
220
|
+
def self.down
|
221
|
+
remove_column :posts, :cached_unique_visits
|
222
|
+
remove_column :posts, :cached_total_visits
|
223
|
+
end
|
224
|
+
end
|
225
|
+
</pre>
|
226
|
+
|
227
|
+
h2. Example
|
228
|
+
|
229
|
+
h3. In your "visitable resource" controller:
|
230
|
+
|
231
|
+
Example: @app/controllers/posts_controller.rb@:
|
232
|
+
|
233
|
+
<pre>
|
234
|
+
class PostsController < ApplicationController
|
235
|
+
|
236
|
+
def show
|
237
|
+
...
|
238
|
+
@post.visit!(:visitor => (current_user.present? ? current_user : request.try(:remote_ip)))
|
239
|
+
...
|
240
|
+
end
|
241
|
+
|
242
|
+
end
|
243
|
+
</pre>
|
244
|
+
|
245
|
+
h2. Dependencies
|
246
|
+
|
247
|
+
For testing: "shoulda":http://github.com/thoughtbot/shoulda, "redgreen":http://gemcutter.org/gems/redgreen, "acts_as_fu":http://github.com/nakajima/acts_as_fu, and "sqlite3-ruby":http://gemcutter.org/gems/sqlite3-ruby.
|
248
|
+
|
249
|
+
h2. Notes
|
250
|
+
|
251
|
+
* Tested with Ruby 1.8.6 - 1.9.1 and Rails 2.3.2 - 2.3.4.
|
252
|
+
* Let me know if you find any bugs; not used in production yet so consider this a concept version.
|
253
|
+
|
254
|
+
h2. TODO
|
255
|
+
|
256
|
+
* documentation: A few more README-examples.
|
257
|
+
* helper: Controller helper taking arguments for DRYer controller code. Example (in controller): handle_visits :by => current_user
|
258
|
+
* feature: Useful finders for @Visitable@.
|
259
|
+
* feature: Useful finders for @Visitor@.
|
260
|
+
* testing: More thorough tests for more complex scenarios.
|
261
|
+
* refactor: Refactor generic stuff to new gem, @is_base@, and add as gem dependency. Reason: Share the same patterns for my very similar ActiveRecord plugins: is_reviewable, is_visitable, is_commentable, and future additions.
|
262
|
+
|
263
|
+
h2. License
|
264
|
+
|
265
|
+
Released under the MIT license.
|
266
|
+
Copyright (c) "Jonas Grimfelt":http://github.com/grimen
|
data/Rakefile
ADDED
@@ -0,0 +1,63 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
require 'rubygems'
|
3
|
+
require 'rake'
|
4
|
+
require 'rake/testtask'
|
5
|
+
require 'rake/rdoctask'
|
6
|
+
|
7
|
+
NAME = "is_visitable"
|
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}"
|
10
|
+
AUTHOR = "Jonas Grimfelt"
|
11
|
+
EMAIL = "grimen@gmail.com"
|
12
|
+
SUPPORT_FILES = %w(README.textile)
|
13
|
+
|
14
|
+
begin
|
15
|
+
gem 'jeweler', '>= 1.0.0'
|
16
|
+
require 'jeweler'
|
17
|
+
|
18
|
+
Jeweler::Tasks.new do |gemspec|
|
19
|
+
gemspec.name = NAME
|
20
|
+
gemspec.summary = SUMMARY
|
21
|
+
gemspec.description = SUMMARY
|
22
|
+
gemspec.homepage = HOMEPAGE
|
23
|
+
gemspec.author = AUTHOR
|
24
|
+
gemspec.email = EMAIL
|
25
|
+
|
26
|
+
gemspec.require_paths = %w{lib}
|
27
|
+
gemspec.files = SUPPORT_FILES << %w(MIT-LICENSE Rakefile) << Dir.glob(File.join(*%w[{generators,lib,test} ** *]).to_s)
|
28
|
+
gemspec.executables = %w[]
|
29
|
+
gemspec.extra_rdoc_files = SUPPORT_FILES
|
30
|
+
|
31
|
+
gemspec.add_dependency 'activerecord', '>= 1.2.3'
|
32
|
+
gemspec.add_dependency 'activesupport', '>= 1.2.3'
|
33
|
+
|
34
|
+
gemspec.add_development_dependency 'test-unit', '= 1.2.3'
|
35
|
+
gemspec.add_development_dependency 'shoulda', '>= 2.10.0'
|
36
|
+
gemspec.add_development_dependency 'redgreen', '>= 0.10.4'
|
37
|
+
gemspec.add_development_dependency 'sqlite3-ruby', '>= 1.2.0'
|
38
|
+
gemspec.add_development_dependency 'acts_as_fu', '>= 0.0.5'
|
39
|
+
end
|
40
|
+
|
41
|
+
Jeweler::GemcutterTasks.new
|
42
|
+
rescue LoadError
|
43
|
+
puts "Jeweler - or one of it's dependencies - is not available. Install it with: sudo gem install jeweler -s http://gemcutter.org"
|
44
|
+
end
|
45
|
+
|
46
|
+
desc %Q{Run unit tests for "#{NAME}".}
|
47
|
+
task :default => :test
|
48
|
+
|
49
|
+
desc %Q{Run unit tests for "#{NAME}".}
|
50
|
+
Rake::TestTask.new(:test) do |test|
|
51
|
+
test.libs << %w[lib test]
|
52
|
+
test.pattern = File.join(*%w[test ** *_test.rb])
|
53
|
+
test.verbose = true
|
54
|
+
end
|
55
|
+
|
56
|
+
desc %Q{Generate documentation for "#{NAME}".}
|
57
|
+
Rake::RDocTask.new(:rdoc) do |rdoc|
|
58
|
+
rdoc.rdoc_dir = 'rdoc'
|
59
|
+
rdoc.title = NAME
|
60
|
+
rdoc.options << '--line-numbers' << '--inline-source' << '--charset=UTF-8'
|
61
|
+
rdoc.rdoc_files.include(SUPPORT_FILES)
|
62
|
+
rdoc.rdoc_files.include(File.join(*%w[lib ** *.rb]))
|
63
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
|
3
|
+
class IsVisitableMigration < ActiveRecord::Migration
|
4
|
+
def self.up
|
5
|
+
create_table :visits do |t|
|
6
|
+
t.references :visitable, :polymorphic => true
|
7
|
+
|
8
|
+
t.references :visitor, :polymorphic => true
|
9
|
+
t.string :ip, :limit => 24
|
10
|
+
|
11
|
+
t.integer :visits, :default => 1
|
12
|
+
|
13
|
+
# created_at <=> first_visited_at
|
14
|
+
# updated_at <=> last_visited_at
|
15
|
+
t.timestamps
|
16
|
+
end
|
17
|
+
|
18
|
+
add_index :visits, [:visitor_id, :visitor_type]
|
19
|
+
add_index :visits, [:visitable_id, :visitable_type]
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.down
|
23
|
+
drop_table :visits
|
24
|
+
end
|
25
|
+
end
|
data/lib/is_visitable.rb
ADDED
@@ -0,0 +1,38 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
require File.join(File.dirname(__FILE__), *%w[is_visitable visit])
|
3
|
+
require File.join(File.dirname(__FILE__), *%w[is_visitable visitor])
|
4
|
+
require File.join(File.dirname(__FILE__), *%w[is_visitable visitable])
|
5
|
+
require File.join(File.dirname(__FILE__), *%w[is_visitable support])
|
6
|
+
|
7
|
+
module IsVisitable
|
8
|
+
|
9
|
+
extend self
|
10
|
+
|
11
|
+
class IsVisitableError < ::StandardError
|
12
|
+
def initialize(message)
|
13
|
+
::IsVisitable.log message, :debug
|
14
|
+
super message
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
InvalidConfigValueError = ::Class.new(IsVisitableError)
|
19
|
+
InvalidVisitorError = ::Class.new(IsVisitableError)
|
20
|
+
InvalidVisitValueError = ::Class.new(IsVisitableError)
|
21
|
+
RecordError = ::Class.new(IsVisitableError)
|
22
|
+
|
23
|
+
mattr_accessor :verbose
|
24
|
+
|
25
|
+
@@verbose = ::Object.const_defined?(:RAILS_ENV) ? (::RAILS_ENV.to_sym == :development) : true
|
26
|
+
|
27
|
+
def log(message, level = :info)
|
28
|
+
return unless @@verbose
|
29
|
+
level = :info if level.blank?
|
30
|
+
@@logger ||= ::Logger.new(::STDOUT)
|
31
|
+
@@logger.send(level.to_sym, message)
|
32
|
+
end
|
33
|
+
|
34
|
+
def root
|
35
|
+
@@root ||= File.expand_path(File.join(File.dirname(__FILE__), *%w[..]))
|
36
|
+
end
|
37
|
+
|
38
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
module IsVisitable
|
2
|
+
module Support
|
3
|
+
|
4
|
+
extend self
|
5
|
+
|
6
|
+
# Shortcut method for generating conditions hash for polymorphic belongs_to-associations.
|
7
|
+
#
|
8
|
+
def polymorphic_conditions_for(object_or_type, field, *match)
|
9
|
+
match = [:id, :type] if match.blank?
|
10
|
+
# Note: {} is equivalent to Hash.new which takes a block, so we must do: ({}) or (Hash.new)
|
11
|
+
returning({}) do |conditions|
|
12
|
+
conditions.merge!(:"#{field}_id" => object_or_type.id) if object_or_type.is_a?(::ActiveRecord::Base) && match.include?(:id)
|
13
|
+
|
14
|
+
if match.include?(:type)
|
15
|
+
type = case object_or_type
|
16
|
+
when ::Class
|
17
|
+
object_or_type.name
|
18
|
+
when ::Symbol, ::String
|
19
|
+
object_or_type.to_s.singularize.classify
|
20
|
+
else # Object - or raise NameError as usual
|
21
|
+
object_or_type.class.name
|
22
|
+
end
|
23
|
+
|
24
|
+
conditions.merge!(:"#{field}_type" => type)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
# Check if object is a valid activerecord object.
|
30
|
+
#
|
31
|
+
def is_active_record?(object)
|
32
|
+
object.present? && object.is_a?(::ActiveRecord::Base) # TODO: ::ActiveModel if Rails 3?
|
33
|
+
end
|
34
|
+
|
35
|
+
# Check if input is a valid format of IP, i.e. "#.#.#.#". Note: Just basic validation.
|
36
|
+
#
|
37
|
+
def is_ip?(object)
|
38
|
+
(object =~ /^\d{1,3}.\d{1,3}.\d{1,3}.\d{1,3}$/) rescue false
|
39
|
+
end
|
40
|
+
|
41
|
+
# Hash conditions to array conditions converter,
|
42
|
+
# e.g. {:key => value} will be turned to: ['key = :key', {:key => value}]
|
43
|
+
#
|
44
|
+
def hash_conditions_as_array(conditions)
|
45
|
+
[conditions.keys.collect { |key| "#{key} = :#{key}" }.join(' AND '), conditions]
|
46
|
+
end
|
47
|
+
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
|
3
|
+
module IsVisitable
|
4
|
+
class Visit < ::ActiveRecord::Base
|
5
|
+
|
6
|
+
ASSOCIATIVE_FIELDS = [
|
7
|
+
:visitable_id,
|
8
|
+
:vistable_type,
|
9
|
+
:visitor_id,
|
10
|
+
:visitor_type,
|
11
|
+
:ip
|
12
|
+
].freeze
|
13
|
+
CONTENT_FIELDS = [
|
14
|
+
:visits
|
15
|
+
].freeze
|
16
|
+
|
17
|
+
# Associations.
|
18
|
+
belongs_to :visitable, :polymorphic => true
|
19
|
+
belongs_to :visitor, :polymorphic => true
|
20
|
+
|
21
|
+
# Aliases.
|
22
|
+
alias :object :visitable
|
23
|
+
alias :owner :visitor
|
24
|
+
|
25
|
+
# Named scopes: Order.
|
26
|
+
named_scope :in_order, :order => 'created_at ASC'
|
27
|
+
named_scope :most_recent, :order => 'created_at DESC'
|
28
|
+
named_scope :lowest_visits, :order => 'visits ASC'
|
29
|
+
named_scope :highest_visits, :order => 'visits DESC'
|
30
|
+
|
31
|
+
# Named scopes: Filters.
|
32
|
+
named_scope :limit, lambda { |number_of_items| {:limit => number_of_items} }
|
33
|
+
named_scope :since, lambda { |created_at_datetime| {:conditions => ['created_at >= ?', created_at_datetime]} }
|
34
|
+
named_scope :recent, lambda { |arg|
|
35
|
+
if [::ActiveSupport::TimeWithZone, ::DateTime].any? { |c| c.is_a?(arg) }
|
36
|
+
{:conditions => ['created_at >= ?', arg]}
|
37
|
+
else
|
38
|
+
{:limit => arg.to_i}
|
39
|
+
end
|
40
|
+
}
|
41
|
+
named_scope :between_dates, lambda { |from_date, to_date| {:conditions => {:created_at => (from_date..to_date)}} }
|
42
|
+
named_scope :with_visits, lambda { |visits_value_or_range| {:conditions => {:visits => visits_value_or_range}} }
|
43
|
+
named_scope :of_visitable_type, lambda { |type| {:conditions => Support.polymorphic_conditions_for(type, :visitable, :type)} }
|
44
|
+
named_scope :by_visitor_type, lambda { |type| {:conditions => Support.polymorphic_conditions_for(type, :visitor, :type)} }
|
45
|
+
named_scope :on, lambda { |visitable| {:conditions => Support.polymorphic_conditions_for(visitable, :visitable)} }
|
46
|
+
named_scope :by, lambda { |visitor| {:conditions => Support.polymorphic_conditions_for(visitor, :visitor)} }
|
47
|
+
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,354 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
require File.join(File.dirname(__FILE__), 'visit')
|
3
|
+
require File.join(File.dirname(__FILE__), 'visitor')
|
4
|
+
|
5
|
+
unless defined?(::Visit)
|
6
|
+
class Visit < ::IsVisitable::Visit
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
module IsVisitable #:nodoc:
|
11
|
+
module Visitable
|
12
|
+
|
13
|
+
ASSOCIATION_CLASS = ::Visit
|
14
|
+
CACHABLE_FIELDS = [
|
15
|
+
:total_visits_count,
|
16
|
+
:unique_visits_count
|
17
|
+
].freeze
|
18
|
+
DEFAULTS = {
|
19
|
+
:accept_ip => false
|
20
|
+
}.freeze
|
21
|
+
|
22
|
+
def self.included(base) #:nodoc:
|
23
|
+
base.class_eval do
|
24
|
+
extend ClassMethods
|
25
|
+
end
|
26
|
+
|
27
|
+
# Checks if this object visitable or not.
|
28
|
+
#
|
29
|
+
def visitable?; false; end
|
30
|
+
alias :is_visitable? :visitable?
|
31
|
+
end
|
32
|
+
|
33
|
+
module ClassMethods
|
34
|
+
|
35
|
+
# Make the model visitable, i.e. count/track visits by user/account of IP.
|
36
|
+
#
|
37
|
+
# * Adds a <tt>has_many :visits</tt> association to the model for easy retrieval of the detailed visits.
|
38
|
+
# * Adds a <tt>has_many :visitors</tt> association to the object.
|
39
|
+
# * Adds a <tt>has_many :visits</tt> associations to the visitor class.
|
40
|
+
#
|
41
|
+
# === Options
|
42
|
+
# * <tt>:options[:visit_class]</tt> - class of the model used for the visits. Defaults to Visit.
|
43
|
+
# This class will be dynamically created if not already defined. If the class is predefined,
|
44
|
+
# it must have in it the following definitions:
|
45
|
+
# <tt>belongs_to :visitable, :polymorphic => true</tt>
|
46
|
+
# <tt>belongs_to :visitor, :class_name => 'User', :foreign_key => :visitor_id</tt> replace user with
|
47
|
+
# the visitor class if needed.
|
48
|
+
# * <tt>:options[:visitor_class]</tt> - class of the model that creates the visit.
|
49
|
+
# Defaults to User or Account - auto-detected. This class will NOT be created, so it must be defined in the app.
|
50
|
+
# Use the IP address to prevent multiple visits from the same client.
|
51
|
+
#
|
52
|
+
def is_visitable(*args)
|
53
|
+
options = args.extract_options!
|
54
|
+
options.reverse_merge!(
|
55
|
+
:by => nil,
|
56
|
+
:accept_ip => options[:anonymous] || DEFAULTS[:accept_ip] # i.e. also accepts unique IPs as visitor
|
57
|
+
)
|
58
|
+
|
59
|
+
# Assocations: Visit class (e.g. Visit).
|
60
|
+
options[:visit_class] = ASSOCIATION_CLASS
|
61
|
+
|
62
|
+
# Had to do this here - not sure why. Subclassing Review be enough? =S
|
63
|
+
options[:visit_class].class_eval do
|
64
|
+
belongs_to :visitable, :polymorphic => true unless self.respond_to?(:visitable)
|
65
|
+
belongs_to :visitor, :polymorphic => true unless self.respond_to?(:visitor)
|
66
|
+
end
|
67
|
+
|
68
|
+
# Visitor class(es).
|
69
|
+
options[:visitor_classes] = [*options[:by]].collect do |class_name|
|
70
|
+
begin
|
71
|
+
class_name.to_s.singularize.classify.constantize
|
72
|
+
rescue NameError => e
|
73
|
+
raise InvalidVisitorError, "Visitor class #{class_name} not defined, needs to be defined. #{e}"
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
# Assocations: Visitor class(es) (e.g. User, Account, ...).
|
78
|
+
options[:visitor_classes].each do |visitor_class|
|
79
|
+
if ::Object.const_defined?(visitor_class.name.to_sym)
|
80
|
+
visitor_class.class_eval do
|
81
|
+
has_many :visits,
|
82
|
+
:foreign_key => :visitor_id,
|
83
|
+
:class_name => options[:visit_class].name
|
84
|
+
|
85
|
+
# Polymorphic has-many-through not supported (has_many :visitables, :through => :visits), so:
|
86
|
+
# TODO: Implement with :join
|
87
|
+
def vistables(*args)
|
88
|
+
query_options = args.extract_options!
|
89
|
+
query_options[:include] = [:visitable]
|
90
|
+
query_options.reverse_merge!(:conditions => Support.polymorphic_conditions_for(self, :visitor))
|
91
|
+
|
92
|
+
::Visit.find(:all, query_options).collect! { |visit| visit.visitable }
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
# Assocations: Visitable class (e.g. Page).
|
99
|
+
self.class_eval do
|
100
|
+
has_many :visits, :as => :visitable, :dependent => :delete_all
|
101
|
+
|
102
|
+
# Polymorphic has-many-through not supported (has_many :visitors, :through => :visits), so:
|
103
|
+
# TODO: Implement with :join
|
104
|
+
def visitors(*args)
|
105
|
+
query_options = args.extract_options!
|
106
|
+
query_options[:include] = [:visitor]
|
107
|
+
query_options.reverse_merge!(:conditions => Support.polymorphic_conditions_for(self, :visitable))
|
108
|
+
|
109
|
+
::Visit.find(:all, query_options).collect! { |visit| visit.visitor }
|
110
|
+
end
|
111
|
+
|
112
|
+
# Hooks.
|
113
|
+
before_create :init_visitable_caching_fields
|
114
|
+
|
115
|
+
include ::IsVisitable::Visitable::InstanceMethods
|
116
|
+
extend ::IsVisitable::Visitable::Finders
|
117
|
+
end
|
118
|
+
|
119
|
+
# Save the initialized options for this class.
|
120
|
+
self.write_inheritable_attribute :is_visitable_options, options.slice
|
121
|
+
self.class_inheritable_reader :is_visitable_options
|
122
|
+
end
|
123
|
+
|
124
|
+
# Does this class count/track visits?
|
125
|
+
#
|
126
|
+
def visitable?
|
127
|
+
@@visitable ||= self.respond_do?(:is_visitable_options, true)
|
128
|
+
end
|
129
|
+
alias :is_visitable? :visitable?
|
130
|
+
|
131
|
+
protected
|
132
|
+
|
133
|
+
# Check if the requested visitor object is a valid visitor.
|
134
|
+
#
|
135
|
+
def validate_visitor(identifiers)
|
136
|
+
raise InvalidVisitorError, "Argument can't be nil: no visitor object or IP provided." if identifiers.blank?
|
137
|
+
visitor = identifiers[:visitor] || identifiers[:user] || identifiers[:account] || identifiers[:ip]
|
138
|
+
is_ip = Support.is_ip?(visitor)
|
139
|
+
visitor = visitor.to_s.strip if is_ip
|
140
|
+
unless Support.is_active_record?(visitor) || is_ip
|
141
|
+
raise InvalidVisitorError, "Visitor is of wrong type: #{visitor.inspect}."
|
142
|
+
end
|
143
|
+
#raise InvalidVisitorError, "Visit based on IP is disabled." if is_ip && !self.is_visitable_options[:accept_ip]
|
144
|
+
visitor
|
145
|
+
end
|
146
|
+
|
147
|
+
end
|
148
|
+
|
149
|
+
module InstanceMethods
|
150
|
+
|
151
|
+
# Does this object count/track visits?
|
152
|
+
#
|
153
|
+
def visitable?
|
154
|
+
self.class.visitable?
|
155
|
+
end
|
156
|
+
alias :is_visitable? :visitable?
|
157
|
+
|
158
|
+
# first_visit = created_at.
|
159
|
+
#
|
160
|
+
def first_visited_at
|
161
|
+
self.created_at if self.respond_to?(:created_at)
|
162
|
+
end
|
163
|
+
|
164
|
+
# last_visit = updated_at.
|
165
|
+
#
|
166
|
+
def last_visited_at
|
167
|
+
self.updated_at if self.respond_to?(:updated_at)
|
168
|
+
end
|
169
|
+
|
170
|
+
# Get the unique number of visits for this object based on the visits field,
|
171
|
+
# or with a SQL query if the visited objects doesn't have the visits field
|
172
|
+
#
|
173
|
+
def unique_visits(recalculate = false)
|
174
|
+
if !recalculate && self.visitable_caching_fields?(:unique_visits)
|
175
|
+
self.unique_visits || 0
|
176
|
+
else
|
177
|
+
::Visit.count(:conditions => self.visitable_conditions)
|
178
|
+
end
|
179
|
+
end
|
180
|
+
alias :number_of_visitors :unique_visits
|
181
|
+
|
182
|
+
# Get the total number of visits for this object.
|
183
|
+
#
|
184
|
+
def total_visits(recalculate = false)
|
185
|
+
if !recalculate && self.visitable_caching_fields?(:total_visits)
|
186
|
+
self.total_visits || 0
|
187
|
+
else
|
188
|
+
::Visit.sum(:visits, :conditions => self.visitable_conditions)
|
189
|
+
end
|
190
|
+
end
|
191
|
+
alias :number_of_visits :total_visits
|
192
|
+
|
193
|
+
# Is this object visited by anyone?
|
194
|
+
#
|
195
|
+
def visited?
|
196
|
+
self.unique_visits > 0
|
197
|
+
end
|
198
|
+
alias :is_visited? :visited?
|
199
|
+
|
200
|
+
# Check if an item was already visited by the given visitor or ip.
|
201
|
+
#
|
202
|
+
# === Identifiers hash:
|
203
|
+
# * <tt>:ip</tt> - identify with IP
|
204
|
+
# * <tt>:visitor</tt> - identify with a visitor-model (e.g. User, ...)
|
205
|
+
# * <tt>:user</tt> - (same as above)
|
206
|
+
# * <tt>:account</tt> - (same as above)
|
207
|
+
#
|
208
|
+
def visited_by?(identifiers)
|
209
|
+
self.visits.exists?(:conditions => visitor_conditions(identifiers))
|
210
|
+
end
|
211
|
+
alias :is_visited_by? :visited_by?
|
212
|
+
|
213
|
+
def visit_by(identifiers)
|
214
|
+
self.visits.find(:first, :conditions => visitor_conditions(identifiers))
|
215
|
+
end
|
216
|
+
|
217
|
+
# Delete all tracked visits for this visitable object.
|
218
|
+
#
|
219
|
+
def reset_visits!
|
220
|
+
self.visits.delete_all
|
221
|
+
self.total_visits = 0 if self.visitable_caching_fields?(:total_visits)
|
222
|
+
self.unique_visits = 0 if self.visitable_caching_fields?(:unique_visits)
|
223
|
+
end
|
224
|
+
|
225
|
+
# View the object with and identifier (user or ip) - create new if new visitor.
|
226
|
+
#
|
227
|
+
# === Identifiers hash:
|
228
|
+
# * <tt>:visitor/:user/:account</tt> - identify with a visitor-model or IP (e.g. User, Account, ..., "128.0.0.1")
|
229
|
+
# * <tt>:*</tt> - Any custom visit field, e.g. :visitor_type => "duck" (optional)
|
230
|
+
#
|
231
|
+
def visit!(identifiers_and_options)
|
232
|
+
begin
|
233
|
+
visitor = self.validate_visitor(identifiers_and_options)
|
234
|
+
visit = self.visit_by(identifiers_and_options)
|
235
|
+
|
236
|
+
# Except for the reserved fields, any Visit-fields should be be able to update.
|
237
|
+
visit_values = identifiers_and_options.except(*::IsVisitable::Visit::ASSOCIATIVE_FIELDS)
|
238
|
+
|
239
|
+
unless visit.present?
|
240
|
+
# An un-existing visitor of this visitable object => Create a new visit.
|
241
|
+
visit = ::Visit.new do |v|
|
242
|
+
v.visitable_id = self.id
|
243
|
+
v.visitable_type = self.class.name
|
244
|
+
|
245
|
+
if Support.is_active_record?(visitor)
|
246
|
+
v.visitor_id = visitor.id
|
247
|
+
v.visitor_type = visitor.class.name
|
248
|
+
else
|
249
|
+
v.ip = visitor
|
250
|
+
end
|
251
|
+
|
252
|
+
v.visits = 0
|
253
|
+
end
|
254
|
+
self.visits << visit
|
255
|
+
else
|
256
|
+
# An existing visitor of this visitable object => Update the existing visit.
|
257
|
+
end
|
258
|
+
is_new_record = visit.new_record?
|
259
|
+
|
260
|
+
# Update non-association attributes and any custom fields.
|
261
|
+
visit.attributes = visit_values.slice(*visit.attribute_names.collect { |an| an.to_sym })
|
262
|
+
|
263
|
+
visit.visits += 1
|
264
|
+
visit.save && self.save_without_validation
|
265
|
+
|
266
|
+
if self.visitable_caching_fields?(:total_visits)
|
267
|
+
begin
|
268
|
+
self.cached_total_visits += 1 if is_new_record
|
269
|
+
rescue
|
270
|
+
self.cached_total_visits = self.total_visits(true)
|
271
|
+
end
|
272
|
+
end
|
273
|
+
|
274
|
+
if self.visitable_caching_fields?(:unique_visits)
|
275
|
+
begin
|
276
|
+
self.cached_unique_visits += 1 if is_new_record
|
277
|
+
rescue
|
278
|
+
self.cached_unique_visits = self.unique_visits(true)
|
279
|
+
end
|
280
|
+
end
|
281
|
+
|
282
|
+
visit
|
283
|
+
rescue InvalidVisitorError => e
|
284
|
+
raise e
|
285
|
+
rescue Exception => e
|
286
|
+
raise RecordError, "Could not create/update visit #{visit.inspect} by #{visitor.inspect}: #{e}"
|
287
|
+
end
|
288
|
+
end
|
289
|
+
|
290
|
+
def unvisit!
|
291
|
+
raise "Not implemented"
|
292
|
+
end
|
293
|
+
|
294
|
+
protected
|
295
|
+
|
296
|
+
# Cachable fields for this visitable class.
|
297
|
+
#
|
298
|
+
def visitable_caching_fields
|
299
|
+
CACHABLE_FIELDS
|
300
|
+
end
|
301
|
+
|
302
|
+
# Checks if there are any cached fields for this visitable/trackable class.
|
303
|
+
#
|
304
|
+
def visitable_caching_fields?(*fields)
|
305
|
+
fields = CACHABLE_FIELDS if fields.blank?
|
306
|
+
fields.all? { |field| self.attributes.has_key?(:"cached_#{field}") }
|
307
|
+
end
|
308
|
+
alias :has_visitable_caching_fields? :visitable_caching_fields?
|
309
|
+
|
310
|
+
# Initialize any cached fields.
|
311
|
+
#
|
312
|
+
def init_visitable_caching_fields
|
313
|
+
self.cached_total_visits = 0 if self.visitable_caching_fields?(:total_visits)
|
314
|
+
self.cached_unique_visits = 0 if self.visitable_caching_fields?(:unique_visits)
|
315
|
+
end
|
316
|
+
|
317
|
+
def visitable_conditions(as_array = false)
|
318
|
+
conditions = {:visitable_id => self.id, :visitable_type => self.class.name}
|
319
|
+
as_array ? Support.hash_conditions_as_array(conditions) : conditions
|
320
|
+
end
|
321
|
+
|
322
|
+
# Generate query conditions.
|
323
|
+
#
|
324
|
+
def visitor_conditions(identifiers, as_array = false)
|
325
|
+
visitor = self.validate_visitor(identifiers)
|
326
|
+
if Support.is_active_record?(visitor)
|
327
|
+
conditions = {:visitor_id => visitor.id, :visitor_type => visitor.class.name}
|
328
|
+
else
|
329
|
+
conditions = {:ip => visitor.to_s}
|
330
|
+
end
|
331
|
+
as_array ? Support.hash_conditions_as_array(conditions) : conditions
|
332
|
+
end
|
333
|
+
|
334
|
+
def validate_visitor(identifiers)
|
335
|
+
self.class.send(:validate_visitor, identifiers)
|
336
|
+
end
|
337
|
+
|
338
|
+
end
|
339
|
+
|
340
|
+
module Finders
|
341
|
+
|
342
|
+
# TODO: Finders
|
343
|
+
#
|
344
|
+
# * users that visited this, also visited [...]
|
345
|
+
|
346
|
+
end
|
347
|
+
|
348
|
+
end
|
349
|
+
end
|
350
|
+
|
351
|
+
# Extend ActiveRecord.
|
352
|
+
::ActiveRecord::Base.class_eval do
|
353
|
+
include ::IsVisitable::Visitable
|
354
|
+
end
|
@@ -0,0 +1,109 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
require 'test_helper'
|
3
|
+
|
4
|
+
class IsVisitableTest < Test::Unit::TestCase
|
5
|
+
|
6
|
+
def setup
|
7
|
+
@visit = ::Visit.new
|
8
|
+
@user_1 = ::User.create
|
9
|
+
@user_2 = ::User.create
|
10
|
+
@tracked_post = ::TrackedPost.create
|
11
|
+
@tracked_post_with_ip = ::TrackedPostWithIp.create
|
12
|
+
end
|
13
|
+
|
14
|
+
context "initialization" do
|
15
|
+
|
16
|
+
should "extend ActiveRecord::Base" do
|
17
|
+
assert_respond_to ::ActiveRecord::Base, :is_visitable
|
18
|
+
end
|
19
|
+
|
20
|
+
should "declare is_visitable instance methods for visitable objects" do
|
21
|
+
methods = [
|
22
|
+
:first_visited_at,
|
23
|
+
:first_visited_at,
|
24
|
+
:last_visited_at,
|
25
|
+
:unique_visits,
|
26
|
+
:total_visits,
|
27
|
+
:visited_by?,
|
28
|
+
:reset_visits!,
|
29
|
+
:visitable?,
|
30
|
+
:visit!
|
31
|
+
]
|
32
|
+
|
33
|
+
assert methods.all? { |m| @tracked_post.respond_to?(m) }
|
34
|
+
# assert !methods.any? { |m| @tracked_post.respond_to?(m) }
|
35
|
+
end
|
36
|
+
|
37
|
+
# Don't work for some reason... =S
|
38
|
+
# should "be enabled only for specified models" do
|
39
|
+
# assert @tracked_post.visitable?
|
40
|
+
# assert_not @untracked_post.visitable?
|
41
|
+
# end
|
42
|
+
|
43
|
+
end
|
44
|
+
|
45
|
+
context "visitable" do
|
46
|
+
should "have zero visits from the beginning" do
|
47
|
+
assert_equal(@tracked_post_with_ip.visits.size, 0)
|
48
|
+
end
|
49
|
+
|
50
|
+
should "count visits based on IP correctly" do
|
51
|
+
number_of_unique_visits = @tracked_post_with_ip.unique_visits
|
52
|
+
number_of_total_visits = @tracked_post_with_ip.total_visits
|
53
|
+
|
54
|
+
@tracked_post_with_ip.visit!(:visitor => '128.0.0.0')
|
55
|
+
@tracked_post_with_ip.visit!(:visitor => '128.0.0.1')
|
56
|
+
@tracked_post_with_ip.visit!(:visitor => '128.0.0.1')
|
57
|
+
|
58
|
+
assert_equal number_of_unique_visits + 2, @tracked_post_with_ip.unique_visits
|
59
|
+
assert_equal number_of_total_visits + 3, @tracked_post_with_ip.total_visits
|
60
|
+
end
|
61
|
+
|
62
|
+
should "count visits based on visitor object (user/account) correctly" do
|
63
|
+
number_of_unique_visits = @tracked_post_with_ip.unique_visits
|
64
|
+
number_of_total_visits = @tracked_post_with_ip.total_visits
|
65
|
+
|
66
|
+
@tracked_post_with_ip.visit!(:visitor => @user_1)
|
67
|
+
@tracked_post_with_ip.visit!(:visitor => @user_2)
|
68
|
+
@tracked_post_with_ip.visit!(:visitor => @user_2)
|
69
|
+
|
70
|
+
assert_equal number_of_unique_visits + 2, @tracked_post_with_ip.unique_visits
|
71
|
+
assert_equal number_of_total_visits + 3, @tracked_post_with_ip.total_visits
|
72
|
+
end
|
73
|
+
|
74
|
+
should "count visits based on both IP and visitor object (user/account) correctly" do
|
75
|
+
number_of_unique_visits = @tracked_post_with_ip.unique_visits
|
76
|
+
number_of_total_visits = @tracked_post_with_ip.total_visits
|
77
|
+
|
78
|
+
@tracked_post_with_ip.visit!(:visitor => '128.0.0.0')
|
79
|
+
@tracked_post_with_ip.visit!(:visitor => '128.0.0.0')
|
80
|
+
@tracked_post_with_ip.visit!(:visitor => @user_1)
|
81
|
+
@tracked_post_with_ip.visit!(:visitor => @user_2)
|
82
|
+
@tracked_post_with_ip.visit!(:visitor => @user_2)
|
83
|
+
|
84
|
+
assert_equal number_of_unique_visits + 3, @tracked_post_with_ip.unique_visits
|
85
|
+
assert_equal number_of_total_visits + 5, @tracked_post_with_ip.total_visits
|
86
|
+
end
|
87
|
+
|
88
|
+
should "delete all visits upon reset" do
|
89
|
+
@tracked_post_with_ip.visit!(:visitor => '128.0.0.0')
|
90
|
+
@tracked_post_with_ip.reset_visits!
|
91
|
+
|
92
|
+
assert_equal 0, @tracked_post_with_ip.unique_visits
|
93
|
+
assert_equal 0, @tracked_post_with_ip.total_visits
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
context "visitor" do
|
98
|
+
|
99
|
+
# Nothing
|
100
|
+
|
101
|
+
end
|
102
|
+
|
103
|
+
context "visit" do
|
104
|
+
|
105
|
+
# Nothing
|
106
|
+
|
107
|
+
end
|
108
|
+
|
109
|
+
end
|
data/test/test_helper.rb
ADDED
@@ -0,0 +1,57 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
require 'rubygems'
|
3
|
+
|
4
|
+
def smart_require(lib_name, gem_name, gem_version = '>= 0.0.0')
|
5
|
+
begin
|
6
|
+
require lib_name if lib_name
|
7
|
+
rescue LoadError
|
8
|
+
if gem_name
|
9
|
+
gem gem_name, gem_version
|
10
|
+
require lib_name if lib_name
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
smart_require 'test/unit', 'test-unit', '= 1.2.3'
|
16
|
+
smart_require 'shoulda', 'thoughtbot-shoulda', '>= 2.10.0'
|
17
|
+
smart_require 'redgreen', 'redgreen', '>= 0.10.4'
|
18
|
+
smart_require 'sqlite3', 'sqlite3-ruby', '>= 1.2.0'
|
19
|
+
smart_require 'acts_as_fu', 'nakajima-acts_as_fu', '>= 0.0.5'
|
20
|
+
|
21
|
+
require 'test_helper'
|
22
|
+
|
23
|
+
require 'is_visitable'
|
24
|
+
|
25
|
+
build_model :visits do
|
26
|
+
references :visitable, :polymorphic => true
|
27
|
+
|
28
|
+
references :visitor, :polymorphic => true
|
29
|
+
string :ip, :limit => 24
|
30
|
+
|
31
|
+
integer :visits, :default => 1
|
32
|
+
|
33
|
+
timestamps
|
34
|
+
end
|
35
|
+
|
36
|
+
build_model :guests
|
37
|
+
build_model :users
|
38
|
+
build_model :accounts
|
39
|
+
build_model :posts
|
40
|
+
|
41
|
+
build_model :untracked_posts do
|
42
|
+
end
|
43
|
+
|
44
|
+
build_model :tracked_posts do
|
45
|
+
is_visitable :by => :users, :accept_ip => false
|
46
|
+
end
|
47
|
+
|
48
|
+
build_model :tracked_post_with_ips do
|
49
|
+
is_visitable :by => [:accounts, :users], :accept_ip => true
|
50
|
+
end
|
51
|
+
|
52
|
+
build_model :cached_tracked_posts do
|
53
|
+
integer :cached_unique_visits
|
54
|
+
integer :cached_total_visits
|
55
|
+
|
56
|
+
is_visitable :by => :users, :accept_ip => true
|
57
|
+
end
|
metadata
ADDED
@@ -0,0 +1,147 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: is_visitable
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Jonas Grimfelt
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
|
12
|
+
date: 2009-11-19 00:00:00 +01:00
|
13
|
+
default_executable:
|
14
|
+
dependencies:
|
15
|
+
- !ruby/object:Gem::Dependency
|
16
|
+
name: activerecord
|
17
|
+
type: :runtime
|
18
|
+
version_requirement:
|
19
|
+
version_requirements: !ruby/object:Gem::Requirement
|
20
|
+
requirements:
|
21
|
+
- - ">="
|
22
|
+
- !ruby/object:Gem::Version
|
23
|
+
version: 1.2.3
|
24
|
+
version:
|
25
|
+
- !ruby/object:Gem::Dependency
|
26
|
+
name: activesupport
|
27
|
+
type: :runtime
|
28
|
+
version_requirement:
|
29
|
+
version_requirements: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 1.2.3
|
34
|
+
version:
|
35
|
+
- !ruby/object:Gem::Dependency
|
36
|
+
name: test-unit
|
37
|
+
type: :development
|
38
|
+
version_requirement:
|
39
|
+
version_requirements: !ruby/object:Gem::Requirement
|
40
|
+
requirements:
|
41
|
+
- - "="
|
42
|
+
- !ruby/object:Gem::Version
|
43
|
+
version: 1.2.3
|
44
|
+
version:
|
45
|
+
- !ruby/object:Gem::Dependency
|
46
|
+
name: shoulda
|
47
|
+
type: :development
|
48
|
+
version_requirement:
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
50
|
+
requirements:
|
51
|
+
- - ">="
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: 2.10.0
|
54
|
+
version:
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: redgreen
|
57
|
+
type: :development
|
58
|
+
version_requirement:
|
59
|
+
version_requirements: !ruby/object:Gem::Requirement
|
60
|
+
requirements:
|
61
|
+
- - ">="
|
62
|
+
- !ruby/object:Gem::Version
|
63
|
+
version: 0.10.4
|
64
|
+
version:
|
65
|
+
- !ruby/object:Gem::Dependency
|
66
|
+
name: sqlite3-ruby
|
67
|
+
type: :development
|
68
|
+
version_requirement:
|
69
|
+
version_requirements: !ruby/object:Gem::Requirement
|
70
|
+
requirements:
|
71
|
+
- - ">="
|
72
|
+
- !ruby/object:Gem::Version
|
73
|
+
version: 1.2.0
|
74
|
+
version:
|
75
|
+
- !ruby/object:Gem::Dependency
|
76
|
+
name: acts_as_fu
|
77
|
+
type: :development
|
78
|
+
version_requirement:
|
79
|
+
version_requirements: !ruby/object:Gem::Requirement
|
80
|
+
requirements:
|
81
|
+
- - ">="
|
82
|
+
- !ruby/object:Gem::Version
|
83
|
+
version: 0.0.5
|
84
|
+
version:
|
85
|
+
description: "Rails: Track unique and total visits/viewings of an ActiveRecord resource based on user/account or IP."
|
86
|
+
email: grimen@gmail.com
|
87
|
+
executables: []
|
88
|
+
|
89
|
+
extensions: []
|
90
|
+
|
91
|
+
extra_rdoc_files:
|
92
|
+
- MIT-LICENSE
|
93
|
+
- README.textile
|
94
|
+
- Rakefile
|
95
|
+
- generators/is_visitable_migration/is_visitable_migration_generator.rb
|
96
|
+
- generators/is_visitable_migration/templates/migration.rb
|
97
|
+
- lib/is_visitable.rb
|
98
|
+
- lib/is_visitable/support.rb
|
99
|
+
- lib/is_visitable/visit.rb
|
100
|
+
- lib/is_visitable/visitable.rb
|
101
|
+
- lib/is_visitable/visitor.rb
|
102
|
+
- test/is_visitable_test.rb
|
103
|
+
- test/test_helper.rb
|
104
|
+
files:
|
105
|
+
- MIT-LICENSE
|
106
|
+
- README.textile
|
107
|
+
- Rakefile
|
108
|
+
- generators/is_visitable_migration/is_visitable_migration_generator.rb
|
109
|
+
- generators/is_visitable_migration/templates/migration.rb
|
110
|
+
- lib/is_visitable.rb
|
111
|
+
- lib/is_visitable/support.rb
|
112
|
+
- lib/is_visitable/visit.rb
|
113
|
+
- lib/is_visitable/visitable.rb
|
114
|
+
- lib/is_visitable/visitor.rb
|
115
|
+
- test/is_visitable_test.rb
|
116
|
+
- test/test_helper.rb
|
117
|
+
has_rdoc: true
|
118
|
+
homepage: http://github.com/grimen/is_visitable
|
119
|
+
licenses: []
|
120
|
+
|
121
|
+
post_install_message:
|
122
|
+
rdoc_options:
|
123
|
+
- --charset=UTF-8
|
124
|
+
require_paths:
|
125
|
+
- lib
|
126
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
127
|
+
requirements:
|
128
|
+
- - ">="
|
129
|
+
- !ruby/object:Gem::Version
|
130
|
+
version: "0"
|
131
|
+
version:
|
132
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
133
|
+
requirements:
|
134
|
+
- - ">="
|
135
|
+
- !ruby/object:Gem::Version
|
136
|
+
version: "0"
|
137
|
+
version:
|
138
|
+
requirements: []
|
139
|
+
|
140
|
+
rubyforge_project:
|
141
|
+
rubygems_version: 1.3.5
|
142
|
+
signing_key:
|
143
|
+
specification_version: 3
|
144
|
+
summary: "Rails: Track unique and total visits/viewings of an ActiveRecord resource based on user/account or IP."
|
145
|
+
test_files:
|
146
|
+
- test/is_visitable_test.rb
|
147
|
+
- test/test_helper.rb
|