module ReallySimple #:nodoc:
module Is #:nodoc:
module Popular #:nodoc:
def self.append_features(base)
super
base.extend(ClassMethods)
# Returns how many days ago thi instance was created, based on t, a Time object, defaults to Time.now
def created_days_ago(t = Time.now)
return ((t-(self.created_at))/(60*60*24)) if self.created_at
end
end
#==== Usage:
# class Post < ActiveRecord::Base #:nodoc:
# is_popular {:votes => {:base => 1, :penalty => 0.019},
# :comments => {:base => 1.3, :penalty => 0.00006},
# :trackbacks => {:base => 1, :penalty => 0.00006},
# :hits => {:base => 0.7, :penalty => 0.00006}},
# {:class_name => "PostPopularity"}
#
# has_many :hits
# has_many :comments
# has_many :votes
# has_many :trackbacks
# end
#
#This indicates that the Post model's popularity is dependent upon votes, comments, trackbacks, and hits.
#Each Vote for a Post is worth 1, with a penalty of .019*n, where n is the how many days ago the vote was created
#A score_option is then {:association => {:base => Value of Instance, :penalty => Degrading Penalty}}
#
#Can be tested via ruby test/unit/is_popular_test.rb
module ClassMethods
#Add this to a ActiveRecord Model to apply popularity, takes score_options, and has_one options
#
#score_options is a hash that contains a hash of models and their popularity options
#
def is_popular(score_options, options = {})
has_one :popularity, :class_name => (options[:class_name] || "#{self.class_name}Popularity"), :dependent => :destroy
class_eval { cattr_accessor :is_popular_options }
self.is_popular_options = {:score_options => score_options}.merge(options)
include InstanceMethods
extend SingletonMethods
end
end
module SingletonMethods
def find_all_in_chunks(chunk_size = 1000, *args)
chunks = []
options = (args.last.is_a?(Hash) ? args.pop : {}) if args
ChunkyIterator.new(self, chunk_size, options)
end
# Finds all instances of model and update's their popularity.
def update_popularities(options = {})
find_all_in_chunks(1000, {:select => "id"}).each{|p| p.update_popularity(options)}
end
#Returns instances of model based on :by options, which can be:
#
# today (default)
# week
# month
# year
# all
#
#=== Usage
# Post.most_popular(:by => "week", :limit => 20)
#
# Supports eager loading, always including the popularity
def most_popular(options = {})
options[:by] ||= "today"
options[:limit] ||= 10
includes = (options[:include] ? options[:include].push(:popularity).uniq : [:popularity])
by_field = case options[:by]
when "today"
"daily_score"
when "week"
"weekly_score"
when "month"
"monthly_score"
when "year"
"yearly_score"
when "all"
"total_score"
end
find_options = options.clone.except(:conditions, :by, :order, :include, :limit).merge(:conditions => ["#{(self.is_popular_options[:class_name] || "#{self.class_name.downcase}_popularities").tableize}.id IS NOT NULL"], :order => "#{(self.is_popular_options[:class_name] || "#{self.class_name.downcase}_popularities").tableize}.#{by_field} DESC" , :limit => options[:limit], :include => includes)
find(:all, find_options)
end
end
module InstanceMethods
#Creates a popularity for an instance
def make_popularity(options)
create_popularity(:score => options[:new_score])
end
#Updates an instance popularity, accepts :now and :new_score options
#:now is the Time that the popularity is being updated, defaults to Time.now
#:new_score is the score of the popularity, defaults to self.score(t)
#where t is :now
#
#===Usage
#@post.update_popularity(:now => (Time.now+2.days), :new_score => (self.score+2))
#Would update @post with an age of 2 days from now and it's score plus an additional 2
#
#update_score is defined in the is_popularity model.
#That method allows for the time based popularitiyes, such as populady today, or this week.
def update_popularity(options = {})
t = options[:now] || Time.now
s = options[:new_score] || self.score(t)
score = self.popularity ? self.popularity.update_score(:new_score => s, :now => t) : self.make_popularity(:new_score => s)
end
#Returns the score of the instance, accepts t param which is a Time instance, defaults to Time.now
#score is calculated by finding all instances of a popularity dependent association
#For each instance, if the model responds to score it will return that value (allows overloading score)
#Otherwise, score is dependent upon the following formula: (b-((0.01+(d*n))*n))
#
#b is the base value of the popularity
#n is the amount of days ago the instance was created from t
#d is the penalty or degredation the instance gets based on it's age
#
#This formula creates a logarithmic decline in value of the instance based on it's age.
#A newly created vote for a Post would be worth it's base amount, losing value each day
#This allows for old instances to become popular again while favoring newly created content.
def score(t = Time.now)
scores = {}
self.class.is_popular_options[:score_options].each do |model, values|
scores[model] = []
self.send(model).each do |o|
if o.respond_to?("score")
r = o.score
else
b = values[:base]
n = o.created_days_ago(t) <= 1 ? 0 : o.created_days_ago(t)
d = values[:penalty]
s = (b-((0.01+(d*n))*n))
r = (s > 0 ? s : 0)
end
scores[model] << r
end
end
return scores.collect{|t,v| v.sum()}.sum()
end
end
class ChunkyIterator
include Enumerable
def initialize(model_class, chunk_size, options)
@model_class = model_class
@chunk_size = chunk_size
@options = options
end
def each
offset = 0
model_objects = @model_class.find(:all, @options.merge(:offset => offset, :limit => @chunk_size))
until model_objects.empty?
while obj = model_objects.shift
yield obj
end
offset += @chunk_size
model_objects = @model_class.find(:all, @options.merge(:offset => offset, :limit => @chunk_size))
end
end
end
end
end
end
ActiveRecord::Base.class_eval { include ReallySimple::Is::Popular }