#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)
A continuación se muestra una captura de pantalla de una aplicación llamada “You Haiku” que permite a los usuarios escribir y añadir Haikus en el sitio web.
Ya se han añadido varios Haikus en la aplicación, y queremos que los usuarios puedan votar a favor o en contra de ellos. Nuestra aplicación todavía no tiene ningún sistema de votaciones, así que ¿cómo podemos añadir esta funcionalidad? Podríamos escribirla desde cero pero en lugar de eso utilizaremos una gema llamada Active Record Reputation System. Esta gema nos permite calcular la valoración medio, sumar el total de votos, etc. En este episodio veremos cómo usarla en nuestra aplicación.
Instalación
Empezaremos añadiendo la gema al Gemfile
y ejecutando bundle
para instalarla. Nótese que tenemos que requerir el fichero como reputation_system
.
gem 'activerecord-reputation-system', require: 'reputation_system'
Tenemos que ejecutar un generador para crear los ficheros de migración con las tablas necesarias. Luego veremos estos ficheros con más detenimiento, pero por ahora simplemente migraremos la base de datos añadiendo las tablas y columnas.
$ rails g reputation_system $ rake db:migrate
A continuación tenemos que modificar el modelo sobre el que queremos que voten los usuarios, añadiendo una llamada a has_reputation
. Pasaremos el nombre que queramos darle a este concepto de reputación (en nuestro caso votos), y dos opciones más: source
, que es el nombre del modelo que realizará la votación, y aggregated_by
que puede valer sum
, average
o product
, dependiendo de cómo queramos que se realicen los cálculos. Las opciones están documentadas en el README así como el resto de opciones que podemos pasar.
class Haiku < ActiveRecord::Base attr_accessible :content belongs_to :user has_reputation :votes, source: :user, aggregated_by: :sum end
Con este código ya podemos emepzar a trabajar en nuestro sistema de votaciones. Queremos tener dos enlaces junto a cada haiku de forma que un usuario pueda votar a favor o en contra, pero ¿a dónde deberían llevar estos enlaces? Tenemos un par de opciones: podríamos generar un nuevo recurso llamado haiku_votes
o bien podríamos hacer acciones para miembros del recurso Haiku
. Seguiremos este último camino añadiendo una opción llamada votes
que recibe peticiones POST.
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
A continuación vamos a añadir la acción del voto al controlador HaikusController
. El valor de un voto debería ser 1
o -1
, dependiendo de si era a favor o en contra. Vamos a usar un parámetro para el tipo de voto y contaremos que es a favor si tiene el valor “up”. Recuperaremos el haiku por su id
e invocaremos un método llamado add_evaluation
. El método recibe tres argumentos: el nombre de la reputación, el valor a añadir y el objeto de origen (que en este caso es el usuario registrado). Finalmente volveremos a la página de origen y mostraremos un mensaje de aviso.
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
A continuación vamos a añadir los enlaces para votar en la plantilla de la vista, en el parcial que muestra el Haiku. Los enlaces apuntan a vote_haiku_path
con un tipo que refleja el carácter del voto.
<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>
Puede ser que tengamos que reiniciar el servidor para que tengan efecto los cambios, tras lo cual tras recargar la página tendríamos que ver los enlaces de votación.
Actualización de los votos
Si votamos a favor de un Haiku y luego cambiamos de idea y votamos de nuevo pero esta vez en contra veremos un mensaje de error de ActiveRecord. Esto es porque el usuario vota dos veces en el mismo Haiku y el sistema de reputación impide automáticamente los votos duplicados. Podríamos hacer un rescue
de esta excepción en el controlador HaikusController
, pero en su lugar utilizaremos un método llamado add_or_update_evaluation
para guardar el voto.
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
Esto actualizará los votos ya existentes. Si ahora un usuario vota más de una vez se actualizará un único voto. Estaría bien poder ver el número de votos recibido por un Haiku, así que lo añadiremos a continuación. Podemos hacerlo invocando reputation_value_for
en el haiku, pasándole la reputación cuyo valor queremos mostrar. Esto devuelve un valor en punto flotante, así que llamamos to_i
para hacer un redondeo hacia abajo.
<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>
Nos gustaría ordenar el listado de haikus basándonos en el número neto de votos a favor. Lo haremos en la acción index
de HaikusController
, en la que ahora mismo simplemente recuperamos los haikus sin más. Podemos utilizar el método find_with_reputation
para recogerlos en el orden correcto.
def index @haikus = Haiku.find_with_reputation(:votes, :all, order: 'votes desc') end
El segundo argumento es el ámbito que queremos usar. Aún no hemos hablado de los ámbitos de reputación, se trata de ámbitos nominales de ActiveRecord pero son especifícos del sistema de reputación, utilizaremos el ámbito :all
para devolverlos todos. Si recargamos la página veremos que ahora se muestran en el orden deseado.
A continuación queremos mostrar el número total de votos que un usuario ha recibido por sus haikus, y el sistema de reputaciones permite definir reputaciones de forma indirecta, por lo que podremos hacerlo de la siguiente manera:
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
Aquí utilizamos has_reputation
con un hash para el origen, con lo que le decimos al sistema de reputación que tiene que delegar en la reputación llamada votes
del modelo Haiku. Se agrega el resultado para dar una puntuación total para el usuario. Podemos usarlo para mostrar con el nombre del usuario en el fichero de layout de la aplicación.
Logged in as <strong><%= current_user.name %></strong> (<%= current_user.reputation_value_for(:votes).to_i %>).
Al recargar la página veremos la puntuacón del usuario actual junto a su nombre.
Cómo mostrar al usuario los haikus por los que ha votado
Para que a los usuarios les resulte sencillo ver los haikus que han votado, ocultaremos los enlaces y los cambiaremos por un breve texto. A pesar de que la gema no incluye una forma clara de hacerlo, sí que es posible. Si miramos los ficheros de migración generados anteriormente veremos que la gema crea una tabla en la base de datos llamada rs_evaluations
, en la que se crea un registro cada vez que un usuario vota.
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
La tabla tiene en cuenta el nombre de la reputación, que en nuestro caso es votes
, la fuente, que en este caso es el modelo User
, el destino (el modelo Haiku
) y el value
que será 1
o -1
dependiendo de si el voto es a favor o en contra. Nótese que tanto source
como target
son asociaciones polimórficas. Hay un modelo RSEvaluation
que refleja los contenidos de esta tabla, lo que quiere decir que podemos asociar un registro de User
con este modelo de la siguiente manera:
has_many :evaluations, class_name: "RSEvaluation", as: :source
Es necesario llevar la opción as:
porque se trata de una asociación polimórfica. Con esto ya podemos determinar si un usuario dado ha votado en un haiku concreto.
def voted_for?(haiku) evaluations.where(target_type: haiku.class, target_id: haiku.id).present? end
Aquí recuperamos todas las evaluaciones para determinar si existe una con el type
e id
correctos. Hay formas más eficientes de hacerlo para páginas en las que aparezca muchas veces, pero de momento con este enfoque nos servirá. Podemos usar el método para ocultar los enlaces si un usuario ya ha emitido un voto.
<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>
Al recargar la página veremos que los enlaces han desaparecido de los haikus para los que ya hemos votado.
Podríamos hacer muchas más cosas en esta aplicación, como por ejemplo añadir esta restricción en la acción del controlador o impedir que un usuario vote por sus propios haikus, pero no lo veremos.
Cómo añadir las votaciones desde cero
Con esto concluimos nuestro repaso rápido de ActiveRecord Reputation System. Se trata de una gema muy útil, pero parece que debería ser fácil reproducir la misma funcionalidad nosotros mismos. De hecho es este el caso, y podemos ver el código fuente en Github. En la parte final del episodio daremos un repaso rápido a este código.
Tenemos un modelo HaikuVote
que pertenece tanto a Haiku
como a User
. Lo mejor de este enfoque es que podemos poner las validaciones que queramos en este caso, incluyendo los valores de los votos, o que un usuario no pueda votar por sus propios 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
Lo más difícil de hacer desde cero es ordenar los haikus basándonos en el número de votos. Esto se implementa en el modelo Haiku
con un método llamado 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
Para que esto funcione hace falta escribir un poco de SQL, pero funcionará.
Otra área difícil es determinar el número de votos recibidos por un usuario, si bien el método joins
de ActiveRecord hace que no tengamos que usar código SQL.
def total_votes HaikuVote.joins(:haiku).where(haikus: {user_id: self.id}).sum('value') end
¿Es mejor escribir esta funcionalidad desde cero, o es mejor usar la gema? La gema es útil si tenemos una configuración más compleja, especialmente si hay múltiples modelos cuya reputación queremos valor. El uso de asociaciones polimórficas puede resultar muy útil. Si tenemos una configuración más simple, como la aplicación de ejemplo que hemos visto, es mejor implementarlo todo desde cero.