Sudoku on Rails, v6

Para esta revisión de nuestra aplicación nos hemos planteado los siguientes objetivos

<ul>
<li>Hacer realmente <em>algo</em> con el modelo del usuario.  Ya tenemos la posibilidad de dar de alta nuevos usuarios y enviarles una clave de activación por correo&#8230; pero no utilizamos esta información de usuario para nada.  La idea es guardar algo así como una tabla de puntuaciones con los nombres de los usuarios que han sido capaces de resolver el rompecabezas en menos tiempo y movimientos.</li>
    <li>Una sugerencia de un lector: incluir la posibilidad de recuperar una cuenta una vez que hemos olvidado la clave (gracias, <strong>Neko</strong>), ampliando el código generado por el plugin <code>acts_as_authenticated</code></li>
    <li>Traducir los mensajes de validación que nos devuelve <code>ActiveRecord</code> cuando tratamos de dar de alta un usuario.</li>
    <li>Por último, aprovecharemos para hacer un pequeño rediseño de la web.  Este último aspecto, siendo más o menos ajeno a Rails, no lo vamos a tratar aquí, pero lo podeis ver directamente en <a href="http://v6.sudokuonrails.com">la aplicación</a></li>
</ul>


<p>Como siempre, podeis descargar el código de la aplicación <a href="http://pantulis.sobrerailes.com/src/sor-6.0.tar.gz">aquí</a>.</p>

        <!--more-->

        <h4>Traducción de los mensajes de validación de <code>ActiveRecord</code></h4>


<p>Pretendemos que aparezcan en castellano los mensajes con errores de validación que muestra nuestra aplicación cuando tratamos dar de alta un nuevo registro en la base de datos (tales como que el campo clave de usuario no coincide, que el usuario o el correo electrónico ya existen, etcétera)    Dichos mensajes son mostrados desde nuestra vista cuando invocamos el <em>helper</em> <code>error_messages_for</code></p>


<p>Inspirándonos <a href="http://barclay.textdrive.com/pipermail/ror-es/2005-November/000467.html">en este hilo de la lista ror-es</a>, vamos a acometer la tarea en varias etapas.</p>


<p>La primera es redefinir el <em>array</em> que usa <code>ActiveRecord</code> para construir los mensajes de error, para ello crearemos un fichero <code>lib/traduccion_validaciones.rb</code> y de momento le añadiremos esto:</p>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
module ActiveRecord
 class Errors
   begin
      @@default_error_messages = {
            :inclusion => "Es obligatorio",
            :exclusion => "Es campo reservado",
            :invalid => "No es válido",
            :confirmation => "No coincide la confirmación",
            :accepted => "Debe ser aceptado",
            :empty => "No puede estar vacío",
            :too_long => "Es demasiado largo (%d caracteres como máximo)",
            :too_short => "Es demasiado corto (%d caracteres como mínimo)",
            :wrong_length => "Debe tener %d caracteres",
            :taken => "Ya existe",
            :not_a_number => "No es un número"
     }
   end
 end
end

A continuación, nos crearemos nuestra propia versión de error_messages_for La versión por defecto nos indica que no ha podido grabarse el registro y nos muestra todos los mensajes de error, uno por uno, en una lista sin numerar. Nosotros preferimos simplemente generar un mensaje que diga que no se ha podido almacenar y luego mostraremos los mensajes de error para cada campo en la vista usando error_message_on (el cual usará el array default_error_messages que ya hemos traducido)

En lib/traduccion_validaciones.rb:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
module ActionView
 module Helpers
  module ActiveRecordHelper
   def mensajes_error_para (object_name, options = {})
        options = options.symbolize_keys
        object = instance_variable_get("@#{object_name}")
        unless object.errors.empty?
        content_tag("div",
          content_tag(
          options[:header_tag] || "h2",
                        "El #{object_name.to_s.gsub("_", " ")} no puede crearse")) 
          end
   end
  end
 end
end

Ahora sólo nos falta invocar este código al cargar nuestra aplicación. El mejor lugar que he encontrado para hacerlo es al principio de config/environment.rb , justo antes de invocar a Rails::Initializer

1
2
3
4
5
6
7
# Bootstrap the Rails environment, frameworks, and default configuration
require File.join(File.dirname(__FILE__), 'boot')
require 'traduccion_validaciones'

Rails::Initializer.run do |config|
  # Settings in config/environments/* take precedence those specified here
...

A continuación veremos cómo hemos usado todo esto en el formulario de alta de usuario (que es el único que tenemos)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
 <%= mensajes_error_para 'usuario' %> 

 <%= start_form_tag %>
 <p><%= error_message_on(:usuario, :login) %><label for="login">Usuario</label><br/>
 <%= text_field 'usuario', 'login' %></p>

 <p><%= error_message_on(:usuario, :email) %><label for="email">Email</label><br/>
 <%= text_field 'usuario', 'email' %></p>

 <p><%= error_message_on(:usuario, :password) %><label for="password">Clave</label><br/>
 <%= password_field 'usuario', 'password' %></p>

 <p><%= error_message_on(:usuario, :password_confirmation) %><label for="password_confirmation">Confirmar clave</label><br/>
 <%= password_field 'usuario', 'password_confirmation' %></p>

 <p><%= submit_tag 'Alta de usuario' %></p>
 <%= end_form_tag %>

Lo cual, combinado con las hojas de estilo termina dando el siguiente aspecto cuando hay algún problema al validar los datos del formulario.

Listado con las mejores puntuaciones

En primer lugar, tendremos que crear una tabla en la base de datos donde almacenar las puntuaciones de las patidas de aquellos jugadores qe estén registrados. Para ello usaremos, como ya vimos en la revisión anterior, las migraciones:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class GeneraTablaPuntuaciones < ActiveRecord::Migration
  def self.up
    create_table :puntuaciones do |t|
      t.column :tiempo,          :integer
      t.column :fecha,           :datetime
      t.column :usuario_id,      :integer
      t.column :movimientos,     :integer
    end
  end
 
  def self.down
    drop_table :puntuaciones
  end
end

Al correr esta migración se creará la correspondiente tabla puntuaciones. Como vemos almacenaremos tanto las jugadas empleadas en terminar de completar el tablero como el tiempo (recordemos que ya llevamos cuenta de ellos en nuestro modelo tablero.rb). Como siempre, para aplicar estos cambios sólo tenemos que hacer

rake migrate

A continuación modificaremos el controlador del tablero (app/controllers/sudoku_controller.rb) Cuando la partida acaba (lo cual se comprueba tras cada movimiento) redirigiremos al usuario al método fin_partida del controlador de la tabla de puntuaciones.

1
2
3
 def acabar
   redirect_to(:controller => 'records', :action =>  'fin_partida')
 end

La acción fin_partida comprueba, obviamente, que el tablero esté resuelto (para evitar que algún usuario astuto pueda querer hacer trampas invocando directamente este método) y a continuación almacena la nueva puntuación en nuestra tabla de puntuaciones

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
 def fin_partida
 if tablero.resuelto? == false
   redirect_to(:controller=>'sudoku',
               :action=>'list')
 end
 
 movimientos = tablero.movimientos
 segundos = tablero.dame_segundos
 
 session[:tablero] = nil
 
 if logged_in? then
     puntuacion = Puntuacion.new
     puntuacion.tiempo = segundos
     puntuacion.movimientos = movimientos
     puntuacion.usuario = current_usuario
     puntuacion.fecha = DateTime.now
 
     if puntuacion.save 
       flash[:notice]="Has terminado la partida en #{segundos} segundos y #{movimientos}              movimientos"
     else
       flash[:notice]="¡No he podido guardar tu puntuacion, lo siento!"
     end
 else
   flash[:notice]="No estás registrado así que no guardaré tu marca personal.  Tu tiempo ha sido #{segundos} segundos, y has hecho #{movimientos} movimientos"
 end
 redirect_to(:action=>'index')
end

En cualquier caso, se redirige a la acción index del controlador de récords cambiando el mensaje que se muestra al usuario. La acción index sencillamente muestra la lista de récords usando para ello un objeto de paginación, que nos permitirá ver las puntuaciones de diez en diez.

1
2
3
def index 
  @paginador_puntuaciones, @puntuaciones = paginate (:puntuacion, :order_by => 'tiempo ASC, movimientos ASC')                       
end

Y en index.rhtml pintamos la tabla de puntuaciones así obtenida

<%= stylesheet_link_tag("tabla_records") %>
<% for puntuacion in @puntuaciones usuario=puntuacion.usuario -%> "> <% end -%>
Listado de mejores partidas
Usuario Fecha Tiempo Movimientos
<%= h(usuario.login) %> <%= puntuacion.fecha.strftime("%d-%m-%y") if puntuacion.fecha %> <%= pinta_hms(puntuacion.tiempo) if puntuacion.tiempo %> <%= puntuacion.movimientos%>

<%= pagination_link (@paginador_puntuaciones) %>

<p>Hemos usado una función de ayuda <code>damehms</code> para mostrar el tiempo empleado en acabar la partida de una manera más cómoda de ver: transformamos un número en segundos en una cadena de texto que dice las horas, minutos y segundos (hasta los días, de ser necesario, aunque creo que los sudokus que generamos no son tan difíciles :-)   Esta función la hemos definido en <code>app/helpers/records_helper.rb</code> de la siguiente manera</p>
module RecordsHelper
    def damehms (tiempo)
      segundos = tiempo % 60
      tiempo /= 60
        minutos = tiempo % 60
      tiempo /= 60
       horas = tiempo % 24
      tiempo /= 24
       {:dias => tiempo,
       :horas => horas,
       :minutos => minutos,
       :segundos => segundos}
    end

    def pinta_hms (tiempo)
      cadena = ""
      if tiempo
        t=damehms(tiempo)
        cadena="#{t[:segundos]} segs"

        if t[:minutos] != 0
          tmp="#{t[:minutos]} min, #{cadena}"
          cadena=tmp
        end

        if t[:horas] != 0
          tmp="#{t[:horas]} hr, #{cadena}"
          cadena=tmp
        end

        if t[:dias] != 0
          tmp="#{t[:dias]} dias, #{cadena}"
          cadena=tmp
        end
      end
      cadena
    end
end

Recuperación de claves

La estrategia será la siguiente: permitir que el usuario recupere su clave enviándole un mensaje a su cuenta de correo con un enlace que le permitirá entrar en la aplicacion sin autenticarse, tras lo cual podrá modificar su clave (recordemos que no le podemos enviar la clave porque ésta se almacena cifrada y el proceso no es reversible) Para ello, primero será necesario permitir que un usuario registrado pueda modificar su clave. Lo haremos en una acción modificar

1
2
3
4
5
6
7
8
9
10
11
def modificar
  return unless request.post? and current_usuario 
  
  current_usuario.password=params[:usuario][:password ]
  current_usuario.password_confirmation=params[:usuario][:password_confirmation]
  
  if current_usuario.save
      redirect_back_or_default(:controller => '/cuenta', :action => 'index')
      flash[:notice] = "Datos del usuario modificados"
  end
end

y en modificar.rhtml

 <% if current_usuario then
     @usuario=current_usuario
 %>

 Configuración del usuario <%= @usuario.login %>.  Aquí puedes cambiar tu clave personal.

 <%= mensajes_error_para (:usuario) %>

 <%= start_form_tag  ({ :action => :modificar })  %>

 

<%= error_message_on(:usuario, :password) %>
<%= password_field 'usuario', 'password' %>

<%= error_message_on(:usuario, :password_confirmation) %>
<%= password_field 'usuario', 'password_confirmation' %>

<%= submit_tag 'Modificar cuenta' %>

<%= end_form_tag %> <% else %> <% end %>

No hay ningún misterio aquí: cuando se invoca la acción modificar siguiendo el correspondiente enlace de la página principal del usuario (método GET) se mostrará el formulario. Cuando el usuario rellene los datos se invocará otra vez el método pero esta vez request.post? devolverá true y modificaremos el objeto del usuario, cambiando los seudocampos password y password_confirmation (recordemos que estos no se guardan en la base de datos) Al invocar el método save, ActiveRecord comprobará que los campos de clave coinciden (porque en nuestro modelo de usuario lo especificamos así con validates_confirmation_of :password) y se generará la clave encriptada para guardarla en la base de datos. Ahora sólo necesitamos que un usuario que haya olvidado su clave pueda acceder a esta página mediante un enlace en su correo. Veamos el método recuperar

1
2
3
4
5
6
7
8
9
10
def recuperar  
   @usuario = Usuario.find_by_email(params[:email])
    return unless @usuario
    
   @usuario.genera_codigo_activacion
   if @usuario.save
     UsuarioNotifier.deliver_clave_perdida(@usuario)
     flash[:notice] = "Se ha enviado un correo con instrucciones a la direccion especificada"
   end
end

El método find_by_email es sintético: no hemos tenido que definirlo en nuestra clase Usuario, sino que ActiveRecord lo genera para nosotros. En general, para cualquier clase que herede de ActiveRecord estarán definidos los método find_by_@XXX siendo XX cualquier columna de la tabla de la base de datos (el mecanismo que usa @ActiveRecord para ello consiste en capturar la excepción de método no definido y ver si el método invocado es de la forma find_by , en cuyo caso realiza la búsqueda por el atributo pertinente y lo devuelve como resultado del método. ¡No intenteis hacer esto en vuestro lenguaje compilado favorito!)

Tras esto, no queda mucho más por hacer, sólo genera el código de activación invocando para ello la acción que ya estaba definida en usuario.rb pero que es privada a la clase, por tanto no la podemos invocar desde el controlador, así que creamos el siguiente método público

1
2
3
 def genera_codigo_activacion
   make_activation_code
 end

Por último, el Notifier de clave_perdida es similar que el ya existente para enviar la clave de activación cuando se produce el alta de usuario. Tras recibir este mensaje, el usuario tendrá un nuevo código de activación y podrá entrar en su cuenta sin introducir la clave para poder crear una nueva.

Conclusión

Aunque pueda parecer lo contrario por la extensión del post, en esta revisión hemos conseguido bastante funcionalidad nueva a cambio de muy poco código real: probablemente lo que más trabajo nos ha costado ha sido el diseño CSS de las tablas de puntuaciones y el nuevo layout de la aplicación.

¿Has encontrado algún fallo? ¿Crees que se podía mejorar algo? ¡Deja tu comentario!

blog comments powered by Disqus