# Copyright (c) 2008 Damian Martinelli # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. module RedmineCrm module ActsAsViewed #:nodoc: # == acts_as_viewed # Adds views count capabilities to any ActiveRecord object. # It has the ability to work with objects that have or don't special fields to keep a tally of the # viewings for each object. # In addition it will by default use the User model as the viewer object and keep the viewings per-user. # It can be configured to use another class. # The IP address are used to not repeat views from the same ip. Only one view are count by user or IP. # # Special methods are provided to create the viewings table and if needed, to add the special fields needed # to keep per-objects viewings fast for access to viewed objects. Can be easily used in migrations. # # == Example of usage: # # class Video < ActiveRecord::Base # acts_as_viewed # end # # In a controller: # # bill = User.find_by_name 'bill' # batman = Video.find_by_title 'Batman' # toystory = Video.find_by_title 'Toy Story' # # batman.view request.remote_addr, bill # toystory.view request.remote_addr, bill # # batman.view_count # => 1 # # module Viewed class ViewedError < RuntimeError; end def self.included(base) #:nodoc: base.extend(ClassMethods) end module ClassMethods # Make the model viewable. # The Viewing model, holding the details of the viewings, will be created dynamically if it doesn't exist. # # * Adds a has_many :viewings association to the model for easy retrieval of the detailed viewings. # * Adds a has_many :viewers association to the object. # * Adds a has_many :viewings associations to the viewer class. # # === Options # * :viewing_class - # class of the model used for the viewings. Defaults to Viewing. This class will be dynamically created if not already defined. # If the class is predefined, it must have in it the following definitions: # belongs_to :viewed, :polymorphic => true # belongs_to :viewer, :class_name => 'User', :foreign_key => :viewer_id replace user with the viewer class if needed. # * :viewer_class - # class of the model that creates the viewing. # Defaults to User This class will NOT be created, so it must be defined in the app. # Use the IP address to prevent multiple viewings from the same client. # def rcrm_acts_as_viewed(options = {}) # don't allow multiple calls return if self.included_modules.include?(ActsAsViewed::Viewed::ViewMethods) send :include, ActsAsViewed::Viewed::ViewMethods # Create the model for ratings if it doesn't yet exist viewing_class = options[:viewing_class] || 'Viewing' viewer_class = options[:viewer_class] || 'User' unless Object.const_defined?(viewing_class) Object.class_eval <<-EOV class #{viewing_class} < ActiveRecord::Base belongs_to :viewed, :polymorphic => true belongs_to :viewer, :class_name => #{viewer_class}, :foreign_key => :viewer_id end EOV end # Rails < 3 # write_inheritable_attribute( :acts_as_viewed_options , # { :viewing_class => viewing_class, # :viewer_class => viewer_class } ) # class_inheritable_reader :acts_as_viewed_options # Rails >= 3 class_attribute :acts_as_viewed_options self.acts_as_viewed_options = { :viewing_class => viewing_class, :viewer_class => viewer_class } class_eval do has_many :viewings, :as => :viewed, :dependent => :delete_all, :class_name => viewing_class.to_s has_many(:viewers, :through => :viewings, :class_name => viewer_class.to_s) before_create :init_viewing_fields end # Add to the User (or whatever the viewer is) a has_many viewings viewer_as_class = viewer_class.constantize return if viewer_as_class.instance_methods.include?('find_in_viewings') viewer_as_class.class_eval <<-EOS has_many :viewings, :foreign_key => :viewer_id, :class_name => #{viewing_class.to_s} EOS end end module ViewMethods def self.included(base) #:nodoc: base.extend ClassMethods end # Is this object viewed already? def viewed? return (!self.views.nil? && self.views > 0) if attributes.has_key? 'views' !viewings.first.nil? end # Get the number of viewings for this object based on the views field, # or with a SQL query if the viewed objects doesn't have the views field def view_count return ("#{self.total_views}(#{self.views})" || 0) if attributes.has_key? 'views' viewings.count end # Change views count (total_views and views) if it's existing in object # If options[:only_total] == true count of unique views doesn't change def increase_views_count(options) if attributes.has_key?('views') && attributes.has_key?('total_views') target = self target.views = ((target.views || 0) + 1) unless options[:only_total] target.total_views = ((target.total_views || 0) + 1) target.save(:validate => false) # target.save_without_validation end end # View the object with or without a viewer - create new or update as needed # # * ip - the viewer ip # * viewer - an object of the viewer class. Must be valid and with an id to be used. Or nil def view(ip, viewer = nil) # Sanity checks for the parameters viewing_class = acts_as_viewed_options[:viewing_class].constantize if viewer && !(acts_as_viewed_options[:viewer_class].constantize === viewer) raise ViewedError, "the viewer object must be the one used when defining acts_as_viewed (or a descendent of it). other objects are not acceptable" end viewing_class.transaction do if !viewed_by? ip, viewer view = viewing_class.new view.viewer_id = viewer.id if viewer && !viewer.id.nil? view.ip = ip viewings << view view.save increase_views_count(:only_total => false) else increase_views_count(:only_total => true) end true end end # Check if an item was already viewed by the given viewer def viewed_by?(ip, viewer = nil) if viewer && !viewer.nil? && !(acts_as_viewed_options[:viewer_class].constantize === viewer) raise ViewedError, "the viewer object must be the one used when defining acts_as_viewed (or a descendent of it). other objects are not acceptable" end if viewer && !viewer.id.nil? && !viewer.anonymous? return viewings.where("viewer_id = '#{viewer.id}'").any? else return viewings.where("ip = '#{ip}'").any? end end private def init_viewing_fields #:nodoc: self.views ||= 0 if attributes.has_key?('views') end end module ClassMethods # Generate the viewings columns on a table, to be used when creating the table # in a migration. This is the preferred way to do in a migration that creates # new tables as it will make it as part of the table creation, and not generate # ALTER TABLE calls after the fact def generate_viewings_columns(table) table.column :views, :integer # uniq views table.column :total_views, :integer end # Create the needed columns for acts_as_viewed. # To be used during migration, but can also be used in other places. def add_viewings_columns if !self.content_columns.find { |c| 'views' == c.name } self.connection.add_column table_name, :views, :integer, :default => '0' self.connection.add_column table_name, :total_views, :integer, :default => '0' self.reset_column_information end end # Remove the acts_as_viewed specific columns added with add_viewings_columns # To be used during migration, but can also be used in other places def remove_viewings_columns if self.content_columns.find { |c| 'views' == c.name } self.connection.remove_column table_name, :views self.connection.remove_column table_name, :total_views self.reset_column_information end end # Create the viewings table # === Options hash: # * :table_name - use a table name other than viewings # To be used during migration, but can also be used in other places def create_viewings_table(options = {}) name = options[:table_name] || :viewings if !self.connection.table_exists?(name) self.connection.create_table(name) do |t| t.column :viewer_id, :integer t.column :viewed_id, :integer t.column :viewed_type, :string t.column :ip, :string, :limit => '24' t.column :created_at, :datetime end self.connection.add_index(name, :viewer_id) self.connection.add_index(name, [:viewed_type, :viewed_id]) end end # Drop the viewings table. # === Options hash: # * :table_name - the name of the viewings table, defaults to viewings # To be used during migration, but can also be used in other places def drop_viewings_table(options = {}) name = options[:table_name] || :viewings if self.connection.table_exists?(name) self.connection.drop_table(name) end end # Find all viewings for a specific viewer. def find_viewed_by(viewer) viewing_class = acts_as_viewed_options[:viewing_class].constantize if !(acts_as_viewed_options[:viewer_class].constantize === viewer) raise ViewedError, "The viewer object must be the one used when defining acts_as_viewed (or a descendent of it). other objects are not acceptable" end raise ViewedError, 'Viewer must be a valid and existing object' if viewer.nil? || viewer.id.nil? raise ViewedError, 'Viewer must be a valid viewer' if !viewing_class.column_names.include?('viewer_id') conds = ['viewed_type = ? AND viewer_id = ?', self.name, viewer.id] acts_as_viewed_options[:viewing_class].constantize.where(conds).collect { |r| r.viewed_type.constantize.find_by_id r.viewed.id } end end end end end ActiveRecord::Base.send :include, RedmineCrm::ActsAsViewed::Viewed