Voting plugin
July 19, 2009
I needed to create a voting functionality for my project and first used act_as_voteable plugin. However, there is a newer and better alternative (which is still supported as well!), called vote_fu, git page is here from Peter Jackson.
The link above gives all the required info on how to install use vote_fu. However, the plugin has a problem – it puts votes as boolean (true/false) in the model, and this does not allow you to use it to show the most popular items sorted by total number of positive votes minus total number of negative votes, it can only count positive votes.
What’s needed is to change the plugin to insert integers as vote value (change column vote to integer). This will allow you to do many things, like quickly calculating the sum of votes and sort results based on that. It can also give you easy functionality for allowing multi-point voting system (anything less than 0 becomes a negative post, anything over – positive).
You can use the modified migration below, line 4 is changed from the original (you can just save it to your migration directory and run it, it will overrun the existing votes table – HOWEVER all historical data will be lost!):
class VoteFuChange < ActiveRecord::Migration
def self.up
create_table :votes, :force => true do |t|
t.integer :vote, :default => -1
t.references :voteable, :polymorphic => true, :null => false
t.references :voter, :polymorphic => true
t.timestamps
end
add_index :votes, ["voter_id", "voter_type"], :name => "fk_voters"
add_index :votes, ["voteable_id", "voteable_type"], :name => "fk_voteables"
# If you want to enfore "One Person, One Vote" rules in the database, uncomment the index below
#add_index :votes, ["voter_id", "voter_type", "voteable_id", "voteable_type"], :unique => true, :name => "uniq_one_vote_only"
end
def self.down
drop_table :votes
end
end
You also need to do some changes to the plugin itself (in directory your rails project/vendor/plugins/lib). What’s needed is to change the methods for calculating the totals. Instead of using count(*), you should use the sum of the vote fields of the required records. As you changed the type of the vote from boolean to integer, you need to also do minor corrections to methods for inserting new votes (instead of true it will now place +1, instead of false -1).
New acts_as_voteable.rb
# ActsAsVoteable
module Juixe
module Acts #:nodoc:
module Voteable #:nodoc:
def self.included(base)
base.extend ClassMethods
end
module ClassMethods
def acts_as_voteable
has_many :votes, :as => :voteable, :dependent => :nullify
include Juixe::Acts::Voteable::InstanceMethods
extend Juixe::Acts::Voteable::SingletonMethods
end
end
# This module contains class methods
module SingletonMethods
# Calculate the vote counts for all voteables of my type.
def tally(options = {})
find(:all, options_for_tally(options.merge({:order =>"count DESC" })))
end
#
# Options:
# :start_at - Restrict the votes to those created after a certain time
# :end_at - Restrict the votes to those created before a certain time
# :conditions - A piece of SQL conditions to add to the query
# :limit - The maximum number of voteables to return
#
rder - A piece of SQL to order by. Eg 'votes.count desc' or 'voteable.created_at desc'
# :at_least - Item must have at least X votes
# :at_most - Item may not have more than X votes
def options_for_tally (options = {})
options.assert_valid_keys :start_at, :end_at, :conditions, :at_least, :at_most,
rder, :limit
scope = scope(:find)
start_at = sanitize_sql(["#{Vote.table_name}.created_at >= ?", options.delete(:start_at)]) if options[:start_at]
end_at = sanitize_sql(["#{Vote.table_name}.created_at <= ?", options.delete(:end_at)]) if options[:end_at]
type_and_context = "#{Vote.table_name}.voteable_type = #{quote_value(base_class.name)}"
conditions = [
type_and_context,
options[:conditions],
start_at,
end_at
]
conditions = conditions.compact.join(' AND ')
conditions = merge_conditions(conditions, scope[:conditions]) if scope
joins = ["LEFT OUTER JOIN #{Vote.table_name} ON #{Vote.table_name}.voteable_id = #{table_name}.#{primary_key}"]
joins << scope[:joins] if scope && scope[:joins]
at_least = sanitize_sql(["COUNT(#{Vote.table_name}.id) >= ?", options.delete(:at_least)]) if options[:at_least]
at_most = sanitize_sql(["COUNT(#{Vote.table_name}.id) <= ?", options.delete(:at_most)]) if options[:at_most]
having = [at_least, at_most].compact.join(' AND ')
group_by = "#{Vote.table_name}.voteable_id"
group_by << " AND #{having}" unless having.blank?
{ :select => "#{table_name}.*, SUM(#{Vote.table_name}.vote) AS count",
:joins => joins.join(" "),
:conditions => conditions,
:group => group_by
}.update(options)
end
end
# This module contains instance methods
module InstanceMethods
def votes_for
Vote.sum(:vote, :conditions => [
"voteable_id = ? AND voteable_type = ? AND vote > 0",
id, self.class.name
])
end
def votes_against
Vote.sum(:vote, :conditions => [
"voteable_id = ? AND voteable_type = ? AND vote < 0",
id, self.class.name
])
end
def votes_total
Vote.sum(:vote, :conditions => [
"voteable_id = ? AND voteable_type = ?",
id, self.class.name
])
end
# Same as voteable.votes.size
def votes_count
self.votes.size
end
def voters_who_voted
voters = []
self.votes.each { |v|
voters << v.voter
}
voters
end
def voted_by?(voter)
rtn = false
if voter
self.votes.each { |v|
rtn = true if (voter.id == v.voter_id && voter.class.name == v.voter_type)
}
end
rtn
end
def vote_by?(voter)
rtn = 0
if voter
self.votes.each { |v|
rtn = v.vote if (voter.id == v.voter_id && voter.class.name == v.voter_type)
}
end
rtn
end
end
end
end
end
New acts_as_voter.rb:
# ActsAsVoter
module PeteOnRails
module Acts #:nodoc:
module Voter #:nodoc:
def self.included(base)
base.extend ClassMethods
end
module ClassMethods
def acts_as_voter
has_many :votes, :as => :voter, :dependent => :nullify # If a voting entity is deleted, keep the votes.
include PeteOnRails::Acts::Voter::InstanceMethods
extend PeteOnRails::Acts::Voter::SingletonMethods
end
end
# This module contains class methods
module SingletonMethods
end
# This module contains instance methods
module InstanceMethods
# Usage user.vote_count(true) # All +1 votes
# user.vote_count(false) # All -1 votes
# user.vote_count() # All votes
def vote_count(for_or_against = "all")
where = (for_or_against == "all") ?
["voter_id = ? AND voter_type = ?", id, self.class.name ] :
["voter_id = ? AND voter_type = ? AND vote = ?", id, self.class.name, for_or_against ]
Vote.count(:all, :conditions => where)
end
def voted_for?(voteable)
0 < Vote.count(:all, :conditions => [
"voter_id = ? AND voter_type = ? AND vote > 0 AND voteable_id = ? AND voteable_type = ?",
self.id, self.class.name, voteable.id, voteable.class.name
])
end
def voted_against?(voteable)
0 < Vote.count(:all, :conditions => [
"voter_id = ? AND voter_type = ? AND vote < 0 AND voteable_id = ? AND voteable_type = ?",
self.id, self.class.name, voteable.id, voteable.class.name
])
end
def voted_on?(voteable)
0 < Vote.count(:all, :conditions => [
"voter_id = ? AND voter_type = ? AND voteable_id = ? AND voteable_type = ?",
self.id, self.class.name, voteable.id, voteable.class.name
])
end
def vote_for(voteable)
self.vote(voteable, 1)
end
def vote_against(voteable)
self.vote(voteable, -1)
end
def vote(voteable, vote)
vote = Vote.new(:vote => vote, :voteable => voteable, :voter => self)
vote.save
end
end
end
end
end
Now, just restart the server and you should use the tally method as you used before (btw, order attribute doesn’t work in the version I used, so you need to modify the code further, if your order is not the default one). Also, you have a new method votes_total, which is equal to votes_for – votes_against, but uses a single DB query.
One final note – the tally will not show any objects that do not have at least one vote, even though the left join is used. I need to research it further, but one workaround is to provide a system vote automatically on the creation of the object.
Entry Filed under: Uncategorized. Tags: count to sum, tally modification, vote_fu.
5 Comments Add your own
Leave a Comment
Some HTML allowed:
<a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <pre> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>
Trackback this post | Subscribe to the comments via RSS Feed
1.
Peter Jackson | July 20, 2009 at 5:21 pm
This is a very well done article and modification.
As it turns out, I’m working on a rewrite of VoteFu to fix a lot of the weaknesses that are in the current version. The number one item on my list is full test coverage, but the number two item on my list is changing Votes to be stored with numeric vote values. This has benefits for the has_karma module as well, since downvotes do not correctly subtract from karma.
I’m aware of the
rder bug and have tackled that in the next version as well.
Thanks a lot for writing this up. Our code for the numeric votes is very similar, so I may end up refactoring mine a little to include some of your changes. I’ll give you a shout out in the README.
2.
allaboutruby | July 20, 2009 at 7:13 pm
Hello Pete, thanks for the approval. I did a minor change to your very useful plug-in. If you need to reuse any of the code pls feel free!
3.
Peter Jackson | July 20, 2009 at 5:21 pm
Ignore that smiley in the third paragraph. “I’m aware of the ordering bug”
4.
Ravicious | July 31, 2009 at 7:52 pm
‘:order’ was changed to ‘ rder’
5.
TM Lee | August 2, 2009 at 3:47 pm
It seems the at_least, at_most is not working
but i changed it to the original which is
group_by = “#{Vote.table_name}.voteable_id HAVING COUNT(#{Vote.table_name}.id) > 0″
and it worked..