#364 Active Record Reputation System
- Download:
- source codeProject Files in Zip (112 KB)
- mp4Full Size H.264 Video (24.5 MB)
- m4vSmaller H.264 Video (12 MB)
- webmFull Size VP8 Video (14.3 MB)
- ogvFull Size Theora Video (27.4 MB)
Below is a screenshot from an application called “You Haiku” which lets users write Haikus and add them to the site.
A number of Haikus have already been added to our application and we’d like users to be able to vote for them by either voting them up or down. Our application has no voting system in place yet so how can we add this feature? We could write this from scratch but instead we’ll use a gem called Active Record Reputation System. This allows us to easily calculate an average user rating, sum up the number of votes and more and here we’ll show you how it get it working in our application.
Getting Started
We’ll start by adding the gem to the gemfile and running bundle to install it. Note that we need to require the file separately as reputation_system
.
gem 'activerecord-reputation-system', require: 'reputation_system'
We’ll need to run a generator to create migration files for the database tables that are needed. We’ll take a closer look at these files later but for now we’ll just migrate the database to add the necessary tables and fields.
$ rails g reputation_system $ rake db:migrate
Next we’ll modify the model that we want users to be able to vote on and add a call to has_reputation
. We pass this the name we want to give the reputation, in this case votes, and two more options: source
, which is the name of the model that will be doing the voting, and aggregated_by
, which can be set to sum
, average
or product
, depending on how we want the calculation to happen. These options are documented in the README along with the other options we can pass in.
class Haiku < ActiveRecord::Base attr_accessible :content belongs_to :user has_reputation :votes, source: :user, aggregated_by: :sum end
With this code in place we can begin to work on our voting system. We want two links next to each haiku so that a user can vote it up or down, but where should these links route to? We have a couple of options here: we could make a separate haiku_votes
resource or we could make this a member action on the Haiku
resource. We’ll take the latter option and add a votes
action that takes POST requests.
Youhaiku::Application.routes.draw do get 'signup', to: 'users#new', as: 'signup' get 'login', to: 'sessions#new', as: 'login' get 'logout', to: 'sessions#destroy', as: 'logout' resources :users resources :sessions resources :haikus do member { post :vote } end root to: 'haikus#index' end
Next we’ll add the vote action to the HaikusController
. The value of a vote should be 1
or -1
, depending on whether it’s a vote up or down. We’ll use a type parameter and count the vote as an upvote if it has the value “up”. Next we’ll fetch the haiku by its id
and call a method called add_evaluation
. This method takes three arguments: the name of the reputation, the value to add and the source object which in this case is the current user. Finally we redirect back to the referrer and display a flash notice.
def vote value = params[:type] == "up" ? 1 : -1 @haiku = Haiku.find(params[:id]) @haiku.add_evaluation(:votes, value, current_user) redirect_to :back, notice: "Thank you for voting!" end
Next we’ll add the voting links in the view template in the partial that displays a Haiku. These links point to the vote_haiku_path
and take a type attribute reflecting the vote type.
<div class="haiku"> <%= simple_format haiku.content %> <em> -- <%= haiku.user.name %> | <%= link_to "up", vote_haiku_path(haiku, type: "up"), method: "post" %> | <%= link_to "down", vote_haiku_path(haiku, type: "down"), method: "post" %> </em> </div>
We may have to restart the server for all these changes to be picked up but once we have we should see the voting links when we reload the page.
Updating Votes
If we vote a Haiku up and then change our mind and vote it down again we’ll see an ActiveRecord error. This is because the same user has voted on the same Haiku twice and the reputation system automatically prevents duplicate votes. We could rescue
from this exception in the HaikusController
but instead we’ll use a different method called add_or_update_evaluation
to record the vote.
def vote value = params[:type] == "up" ? 1 : -1 @haiku = Haiku.find(params[:id]) @haiku.add_or_update_evaluation(:votes, value, current_user) redirect_to :back, notice: "Thank you for voting!" end
This will update an existing vote if one if found. Now if a user votes more than once their current vote will be changed. It would be nice if we could see the number of votes a Haiku has received so we’ll add this next. We can do this by calling reputation_value_for
on the haiku and passing in the reputation that we want the value for. This returns a float value so we call to_i
on it to round it down.
<div class="haiku"> <%= simple_format haiku.content %> <em> -- <%= haiku.user.name %> | <%= pluralize haiku.reputation_value_for(:votes).to_i, "vote" %> | <%= link_to "up", vote_haiku_path(haiku, type: "up"), method: "post" %> | <%= link_to "down", vote_haiku_path(haiku, type: "down"), method: "post" %> </em> </div>
It would be good to sort the list of haikus based on the net number of upvotes. We’ll do this inside the HaikusController
’s index
action where we currently just fetch all the haikus. We can instead use find_with_reputation
to fetch them in the correct order.
def index @haikus = Haiku.find_with_reputation(:votes, :all, order: 'votes desc') end
The second argument here is the scope we want to use. We haven’t discussed reputation scopes yet. These aren’t ActiveRecord named scopes but are specific to the Reputation System and we use the :all
scope here to find everything. When we reload the page now the haikus are displayed in the correct order.
Next we want to display the total number of votes that a user has received for their haikus and the Reputation System allows to to define reputations indirectly so we can go about it like this.
class User < ActiveRecord::Base has_secure_password attr_accessible :name, :password, :password_confirmation validates_uniqueness_of :name has_many :haikus has_reputation :votes, source: {reputation: :votes, of: :haikus}, aggregated_by: :sum end
Here we use has_reputation with a hash for the source. This instructs the Reputation System to delegate this to the reputation called votes in the Haiku model. We aggregate the result of this to give an overall score for the user. We can now use this where we display the user’s name in the application’s layout file.
Logged in as <strong><%= current_user.name %></strong> (<%= current_user.reputation_value_for(:votes).to_i %>).
When we reload the page now we’ll see the current user’s score next to the user’s name.
Showing The User The Haikus They Have Voted for
To make it easier for a user to see which haikus they have voted for we’ll show something next to those haikus and hide the links. The gem doesn’t seem to provide a way to do this but it’s still possible. If we look at one of the migration files that was generated earlier we’ll see that the gem creates a database table called rs_evaluations
and a record is added here when a user votes.
def self.up create_table :rs_evaluations do |t| t.string :reputation_name t.references :source, :polymorphic => true t.references :target, :polymorphic => true t.float :value, :default => 0 t.timestamps end # Rest of migration omitted end
This table keeps track of the reputation name, which in our case is votes
, the source
, which is the User
model, the target (the Haiku
model) and the value
which will be 1
or -1
depending on whether the vote was up or down. Note that both source
and target
are polymorphic associations. There is an RSEvaluation
model that goes along with this table and this means that we can associate a User
record with that model like this:
has_many :evaluations, class_name: "RSEvaluation", as: :source
The as:
option is necessary here as this is a polymorphic association. With this in place we can determine whether a given user has voted for a specific haiku.
def voted_for?(haiku) evaluations.where(target_type: haiku.class, target_id: haiku.id).present? end
Here we fetch all the evaluations and determine whether one exists with correct type
and id
. There are more efficient ways to do this if we’re doing this a lot on a single page but this approach will work for us here. We can use this method now to hide the links if a user has already voted.
<div class="haiku"> <%= simple_format haiku.content %> <em> -- <%= haiku.user.name %> | <%= pluralize haiku.reputation_value_for(:votes).to_i, "vote" %> <% if current_user && !current_user.voted_for?(haiku) %> | <%= link_to "up", vote_haiku_path(haiku, type: "up"), method: "post" %> | <%= link_to "down", vote_haiku_path(haiku, type: "down"), method: "post" %> <% end %> </em> </div>
When we reload the page now the links have disappeared from the haikus that we’ve voted for.
There’s more that we could do with this application such as adding this restriction to the controller action and also preventing a user from voting for their own haikus but we won’t do that here.
Adding Voting From Scratch
That’s it for our quick tour of the ActiveRecord Reputation System. It’s a handy gem but it seems that it would be fairly easy to reproduce the same functionality from scratch. This is indeed the case and you can see the source code for this on Github. For the final part of this episode we’ll walk quickly through this code.
Here we have a HaikuVote
model that belongs to both a Haiku
and a User
. What’s nice about this approach is that we can put custom validations here, including the vote values that we accept and whether or not the user that is voting is voting for one of their own haikus.
class HaikuVote < ActiveRecord::Base attr_accessible :value, :haiku, :haiku_id belongs_to :haiku belongs_to :user validates_uniqueness_of :haiku_id, scope: :user_id validates_inclusion_of :value, in: [1, -1] validate :ensure_not_author def ensure_not_author errors.add :user_id, "is the author of the haiku" if haiku.user_id == user_id end end
The most difficult thing to do from scratch is sorting the haikus based on the number of votes. This is implemented in the Haiku
model in a class method called by_votes
.
class Haiku < ActiveRecord::Base attr_accessible :content belongs_to :user has_many :haiku_votes def self.by_votes select('haikus.*, coalesce(value, 0) as votes'). joins('left join haiku_votes on haiku_id=haikus.id'). order('votes desc') end def votes read_attribute(:votes) || haiku_votes.sum(:value) end end
It takes some SQL code to get this functionality working, but it does work.
Another tricky area is determining the number of votes that a user has received, although ActiveRecord’s joins
method means that we don’t need to use SQL code here.
def total_votes HaikuVote.joins(:haiku).where(haikus: {user_id: self.id}).sum('value') end
So, is it best to write this functionality from scratch or use the gem? The gem is useful if we have a more complicated setup, especially if we have multiple models that we handling the reputation of. It’s use of polymorphic associations can really help here. If we have a simpler setup, like the example application we’ve shown here then starting from scratch is the better option.