state_machineは同じ状態が継続するeventの場合、どのように処理すべきだろう?

前回から引き続き、state_machineを利用して試行錯誤中。(パスワード忘れの処理を追加したい!)

  • 現状は「パスワードを忘れ?」のリンクをクリックしたら、状態を:activeから:resetに遷移させて、パスワード再設定のリンクをメールで送信する仕様になっている。
# ---------- app/models/user.rb ----------
...(中略)...
        acts_as_state_machine :initial => :pending
        state :passive
        state :pending, :enter => :make_activation_code
        state :active,  :enter => :do_activate
        state :suspended
        state :deleted, :enter => :do_delete
        state :reset,   :enter => :forgot_password, :exit => :reset_password
...(中略)...
        event :activate do
          transitions :from => :pending, :to => :active 
        end

        event :forgot_password do
          transitions :from => :active, :to => :reset
        end

        event :reset_password do
          transitions :from => :reset, :to => :active, :guard => Proc.new {|u| u.valid? }
        end
...(中略)...
      end
    end
  • ところが一つ問題があって、もし何らかの理由で状態は:resetに遷移しても、メールが送信されなかったり、またはユーザー側の迷惑メールフィルタで削除されてしまったりしたら、上記の設定ではユーザーとしてはなす術が無くなってしまう...。
    • メールが届かなければ、ユーザーはおそらく再度「パスワードを忘れ?」の手続きを実行すると思うが、既に状態が:resetになっている場合は、eventが発生しても何も起こらないのだ。
    • event :forgot_passwordブロック内のtransitionsで設定しているのは、:activeから:resetへの遷移だけなので、それ以外の状態遷移は出来ない扱いになっている。

状態を継続するevent

  • そこで、:formと:toの状態が同じ、つまり状態を継続させるeventを作ってみた。
# ---------- app/models/user.rb ----------
...(中略)...
        event :forgot_password do
          transitions :from => [:active, :reset], :to => :reset
        end
...(中略)...
  • 一見うまく動きそうなのだが、実際には問題あり。state_machineは状態が遷移しない場合、stateで設定した:enterや:exit、:afterを何も処理してくれない。
    • だから、状態が:resetで再度「パスワードを忘れ?」の手続きをしても、パスワード再設定のメールは送信されないのだ...。
    • しかし、ForgotPasswordsControllerは以下のようになっているので、見た目上は「メールを送信しました」と正常に処理されたように表示されてしまうのだ。始末が悪い。
# ---------- app/controllers/forgot_password_controller.rb ----------
class ForgotPasswordsController < ApplicationController
...(中略)...
  # Forgot password action
  def create
    # if params[:email]重要!! これがないとnilの場合、DB先頭のユーザーが代入されてしまう
    @user = User.find_by_email(params[:email], :conditions=>'activated_at IS NOT NULL') unless params[:email].blank?
    
    case
    when !@user
      flash.now[:error] = _("Could not find a user with that email address.")
      render :action => 'new'
    when @user.forgot_password! == true
      flash[:notice] = _("A password reset link has been sent to your email address.")
      redirect_to login_path
    else
      flash.now[:error] = _("A password reset link could not be sent.")
      render :action => 'new'
    end
  end
...(中略)...

eventを呼び出した後の戻り値

  • eventが何を返してくるか調べてみた。実験結果より、以下のことを理解した。
    • transitionsで指定した状態遷移が完了した場合(fromとtoが同じ継続する場合も含む)trueが返る。
    • 状態遷移できなければ、[]空の配列が返る。
    • :guardオプションで制限された場合は、StateTransitionオブジェクトが返る。

とにかく、状態遷移に成功すればtrueが返る、ということのようだ。

# 状態遷移する場合、しない場合
# event :forgot_password do
#   transitions :from => :active, :to => :reset
# end
>> @user.state
=> "active"
>> @user.forgot_password!
=> true
>> @user.state
=> "reset"
>> @user.forgot_password!
=> []
        
# 同じ状態が継続される場合
# event :forgot_password do
#   transitions :from => [:active, :reset], :to => :reset
# end
>> @user.state
=> "reset"
>> @user.forgot_password!
=> true
# trueは返るが、:exit・:enter・:afterで指定されるフック処理は実行されない
        
# :guardオプションによって、状態遷移が阻止される場合
# event :forgot_password do
#   transitions :from => :active, :to => :reset, :guard => Proc.new {|u| false}
# end
>> @user.forgot_password!
=> #

状態が継続する場合の処理をコントローラーで制御してみる

上記実験の結果を踏まえて、:reset状態が継続する場合でもパスワード再設定のメールを送信するように以下のように変更してみた。

  • 状態遷移は:activeから:resetのみ可能とした。
# ---------- app/models/user.rb ----------
...(中略)...
        event :forgot_password do
          transitions :from => :active, :to => :reset
        end
...(中略)...
  • 状態遷移しなかった場合でも、その時:reset状態ならforgot_passwordのフック処理を実行するようにした。
# ---------- app/controllers/forgot_password_controller.rb ----------
class ForgotPasswordsController < ApplicationController
...(中略)...
  # Forgot password action
  def create
    # if params[:email]重要!! これがないとnilの場合、DB先頭のユーザーが代入されてしまう
    @user = User.find_by_email(params[:email], :conditions=>'activated_at IS NOT NULL') unless params[:email].blank?
    
    case
    when !@user
      flash.now[:error] = _("Could not find a user with that email address.")
      render :action => 'new'
    when @user.forgot_password! == true
      flash[:notice] = _("A password reset link has been sent to your email address.")
      redirect_to login_path
    when @user.reset?
      @user.send('forgot_password')
      @user.save
      flash[:notice] = _("A password reset link has been sent to your email address again.")
      redirect_to login_path
    else
      flash.now[:error] = _("A password reset link could not be sent.")
      render :action => 'new'
    end
  end
...(中略)...

これでメールの再送は可能になったが、満足感はない...。本来はstate_machineが判断して、上記のような処理を実行して欲しいものだ。コントローラーが気を遣い過ぎている気がする。

状態が継続する場合でも:exit・:enterを処理するstate_machineにしてみる

そもそも、状態を継続する場合のstate_machineの挙動が、:enterも処理するようになっていれば、このような気を遣った処理は不要になるのだ。そのようなstate_machineに修正できないだろうか?

  • ここで自分がどのようなstate_machineを望んでいるのか整理してみた。
# 処理の流れ
#   1. acts_as_state_machineのeventブロックの:guardオプションの処理
#   2. acts_as_state_machineのstate :exitオプションの処理
#   3. acts_as_state_machineのstate :enterオプションの処理
#   4. ActiveRecord::Base.before_save
#   5. ActiveRecord::Base.save
#   6. ActiveRecord::Base.after_save
#   7. acts_as_state_machineのstate :afterオプションの処理
#
# 同じ状態を継続するeventの場合は...(transitions :from => :B, :to => :B)
#   * :enter、:afterのみ処理される
#   * :exitは無視される
#
#                         +---------+
#                         |         |
# +-------+               |  +------------+
# |   A   |               |  |      B     |
# |       |               v  | before_save|
# |       |exit ------> enter| save       |
# |       |                  | after_save |
# |       |                  | after      |
# +-------+                  +------------+
#
  • 上記のようなstate_machineにするためには、前回と同じくvendor/plugins/acts_as_state_machine/lib/acts_as_state_machine.rbの52行目performメソッドの定義で調整できそうだ。
  • どうやらloopbackという変数が、状態が継続する場合のキーになっているようなので、以下のように変更してみた。(2カ所コメントアウトしただけ)
...(中略)...
          def perform(record)
            return false unless guard(record)
            loopback = record.current_state == to
            states = record.class.read_inheritable_attribute(:states)
            next_state = states[to]
            old_state = states[record.current_state]
            
            old_state.exited(record) unless loopback ####:exitの順序変更
            next_state.entering(record)# unless loopback
            
            record.update_attribute(record.class.state_column, to.to_s)
            
            next_state.entered(record)# unless loopback
            # old_state.exited(record) unless loopback ####:exitの順序変更
            true
          end
...(中略)...

たったこれだけの修正で、restful_authentication --statefulのパスワード忘れの処理は以下のように書けるようになった。満足!

      • state_machineの挙動をこのように変更してしまうと、後でまた別の問題が発生するかもしれない...。しかし、まだ経験不足なのでどのような問題が発生するか予想できない。ひとまず、このまま使い続けてみることにした。
# ---------- vendor/plugins/restful_authentication/lib/authorization/stateful_roles.rb ----------
module Authorization
  module StatefulRoles
    unless Object.constants.include? "STATEFUL_ROLES_CONSTANTS_DEFINED"
      STATEFUL_ROLES_CONSTANTS_DEFINED = 'yup' # sorry for the C idiom
    end
    
    def self.included( recipient )
      recipient.extend( StatefulRolesClassMethods )
      recipient.class_eval do
        include StatefulRolesInstanceMethods

        acts_as_state_machine :initial => :pending
        state :passive
        state :pending, :enter => :make_activation_code
        state :active,  :enter => :do_activate
        state :suspended
        state :deleted, :enter => :do_delete
        state :reset,   :enter => :forgot_password, :exit => :reset_password

        event :register do
          transitions :from => :passive, :to => :pending, :guard => Proc.new {|u| !(u.crypted_password.blank? && u.password.blank?) }
        end
        
        event :activate do
          transitions :from => :pending, :to => :active 
        end
        
        event :suspend do
          transitions :from => [:passive, :pending, :active], :to => :suspended
        end
        
        event :delete do
          transitions :from => [:passive, :pending, :active, :suspended], :to => :deleted
        end

        event :unsuspend do
          transitions :from => :suspended, :to => :active,  :guard => Proc.new {|u| !u.activated_at.blank? }
          transitions :from => :suspended, :to => :pending, :guard => Proc.new {|u| !u.activation_code.blank? }
          transitions :from => :suspended, :to => :passive
        end

        event :forgot_password do
          transitions :from => [:active, :reset], :to => :reset
        end

        event :reset_password do
          transitions :from => :reset, :to => :active, :guard => Proc.new {|u| u.valid? }
        end
      end
    end

    module StatefulRolesClassMethods
    end # class methods

    module StatefulRolesInstanceMethods
      # used in user_observer
      # Returns true if the user has just been activated.
      def recently_activated?
        @activated
      end

      # Returns true if the user has just been forgotten password.
      def recently_forgot_password?
        @forgotten_password
      end

      # Returns true if the user has just been reset password.
      def recently_reset_password?
        @reset_password
      end

      protected

        def make_activation_code
          self.deleted_at = nil
          self.activation_code = self.class.make_token
        end

        def do_activate
          # :pendingから:activeへの遷移の時だけ処理するため
          if pending?
            @activated = true
            self.activated_at = Time.now.utc
          end
        end

        def do_delete
          self.deleted_at = Time.now.utc
        end

        def forgot_password
          @forgotten_password = true
          self.password_reset_code = self.class.make_token
        end

        def reset_password
          @reset_password = true
          self.password_reset_code = nil
        end

    end # instance methods
  end
end
# ---------- app/controllers/forgot_password_controller.rb ----------
class ForgotPasswordsController < ApplicationController
  before_filter :not_logged_in_required

  verify :method=>:post, :only=>:create, :redirect_to=>{:action=>:new}
  verify :method=>:put, :only=>:update, :redirect_to=>{:action=>:edit}

  # Enter email address to recover password 
  def new
  end

  # Forgot password action
  def create
    # if params[:email]重要!! これがないとnilの場合、DB先頭のユーザーが代入されてしまう
    @user = User.find_by_email(params[:email], :conditions=>'activated_at IS NOT NULL') unless params[:email].blank?
    
    case
    when !@user
      flash.now[:error] = _("Could not find a user with that email address.")
      render :action => 'new'
    when @user.forgot_password! == true
      flash[:notice] = _("A password reset link has been sent to your email address.")
      redirect_to login_path
    else
      flash.now[:error] = _("A password reset link could not be sent.")
      render :action => 'new'
    end
  end
   
  # Action triggered by clicking on the /reset_password/:id link recieved via email
  # Makes sure the id code is included
  # Checks that the id code matches a user in the database
  # Then if everything checks out, shows the password reset fields
  def edit
    # if params[:id]重要!! これがないとnilの場合、DB先頭のユーザーが代入されてしまう
    @user = User.find_by_password_reset_code(params[:id]) if params[:id]
    raise if @user.nil?
    
  rescue
    logger.error "Invalid Reset Code entered."
    flash[:notice] = _("Sorry - That is an invalid password reset code. Please check your code and try again. (Perhaps your email client inserted a carriage return?)")
    redirect_to new_forgot_password_path
  end

  # Reset password action /reset_password/:id
  # Checks once again that an id is included and makes sure that the password field isn't blank
  def update
    # if params[:id]重要!! これがないとnilの場合、DB先頭のユーザーが代入されてしまう
    @user = User.find_by_password_reset_code(params[:id]) if params[:id]
    raise if @user.nil?
    
    @user.password              = params[:user][:password]
    @user.password_confirmation = params[:user][:password_confirmation]
    
    if @user.reset_password! == true
      flash[:notice] = _("Password reset.")
      redirect_to login_path
    else
      flash.now[:error] = _("Password mismatch.")
      render :action=>'edit', :id=>params[:id]
    end
    
  rescue
    logger.error "Cracking?"
    flash[:error] = _("Sorry - Password not reset.")
    redirect_to login_path
  end     
end
<%# ---------- app/views/forgot_passwords/new.html.erb ---------- %>
<div class="password">
<h3><%= _("Forgot Password?") %></h3>

<% form_tag url_for(:action => 'create') do %>
  <p><%= label_tag :email, _("What is the email address used to create your account?") %><br />
  <%= text_field_tag :email, "", :size => 30, :autocomplete=>'off' %></p>

  <p>
    <%= link_to _('Cancel'), login_path, :id=>'cancel_link' %>
    <%= submit_tag _('Reset Password'), :disable_with=>_('Reset Password...') %>
  </p>
<% end %>
</div>
<%# ---------- app/views/forgot_passwords/edit.html.erb ---------- %>
<div class="password">
<h3><%= _("Reset Password") %></h3>

<% simple_form_for @user, :url=>{:action => "update", :id => params[:id]} do |f| %>
  <%= f.password_field :password %>
  <%= f.password_field :password_confirmation %>

  <p>
    <%#= link_to 'Cancel', login_path, :id=>'cancel_link' %>
    <%= submit_tag _('Reset Your Password'), :disable_with=>_('Reset Your Password...') %>
  </p>
<% end %>
</div>