is_visitable 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 +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
|