is_visitable 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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.
@@ -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
@@ -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,12 @@
1
+ # coding: utf-8
2
+
3
+ class IsVisitableMigrationGenerator < Rails::Generator::Base
4
+
5
+ def manifest
6
+ record do |m|
7
+ m.migration_template 'migration.rb',
8
+ File.join('db', 'migrate'), :migration_file_name => 'is_visitable_migration'
9
+ end
10
+ end
11
+
12
+ 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
@@ -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,7 @@
1
+ # coding: utf-8
2
+
3
+ module IsVisitable #:nodoc:
4
+ module Visitor
5
+
6
+ end
7
+ 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
@@ -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