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.
@@ -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