Beyond Callbacks for complex model lifecycles
The standard Active Record callbacks pull their weight in gold, but sometimes we need to manage a more complex object lifecycle.Scott Barron's Acts as State Machine is a gem of a plugin, which unforunately, was never well publicised.Cluttered callbacks/observers and additional database columns that doesn't quite feel DRY? Read on ...
Installation
./script/plugin install -x http://elitists.textdriven.com/svn/plugins/acts_as_state_machine/trunk acts_as_state_machine
Example implementation
I'll use a Booking model as an example.Requirements being a lifecycle of states that includes In Progress, Pending, In Review, Cancelled, Confirmed, Awaiting Payment and Service Rendered
class Booking < ActiveRecord::Base
include Comparable
self.abstract_class = true
set_table_name 'bookings'
acts_as_recent 1.days
attr_accessor :payment_option
belongs_to :account
belongs_to :user
belongs_to :coupon
has_one :date, :class_name => 'BookingDate', :dependent => :destroy, :foreign_key => :booking_id
has_one :customer, :dependent => :destroy
has_one :payment, :as => :payable, :dependent => :destroy
has_one :progress, :class_name => 'BookingProgress', :dependent => :destroy
has_one :token, :class_name => 'PaymentToken', :dependent => :destroy
has_many :booking_products, :class_name => 'BookingProduct', :foreign_key => :booking_id, :dependent => :delete_all, :after_add => [Proc.new{|b,bp| bp.booking_extras.collect{|be| be.save! }}, Proc.new{|b,bp| bp.product.extras.compulsary_included.collect{|e| bp.extras << e }}]
has_many :booking_extras, :include => [:extra, :booking_product], :dependent => :delete_all
has_many :products, :through => :booking_products, :source => :product, :uniq => true
has_many :extras, :through => :booking_extras, :source => :extra, :uniq => true
has_many :events, :class_name => 'BookingEvent', :dependent => :delete_all, :order => 'booking_events.created_at DESC', :extend => BookingEventsExtension
has_many :extras, :through => :line_items_extras, :source => :extra
has_many :notes, :class_name => 'BookingNote', :dependent => :delete_all, :after_add => Proc.new {|b,bn| BookingMailer.deliver_note( bn )}, :extend => BookingNotesExtension
delegate :to_s, :to => :name
delegate :duration, :to => :date
delegate :date_from, :date_to, :duration, :to => :date
delegate :any_progress?, :to => :progress
validates_presence_of :reference, :account_id, :user_id
validates_uniqueness_of :reference, :scope => 'account_id', :on => :create
validates_length_of :reference, :is => 10
acts_as_state_machine :initial => :in_progress, :column => 'status'
state :in_progress
state :pending, :enter => Proc.new{|b| b.commit!; BookingMailer.deliver_received( b ); }
state :awaiting_payment, :enter => Proc.new{|b| ( b.create_token({ :account_id => b.account.id, :booking_id => b.id }) if b.token.nil? ); BookingMailer.deliver_payment( b ); }, :after => Proc.new{|b| b.log( 'Status set to %s.' / b.current_status_to_human ) }
state :in_review, :enter => Proc.new{|b| BookingMailer.deliver_review( b ) }, :after => Proc.new{|b| b.log( 'Booking status set to %s.' / b.current_status_to_human ) }
state :cancelled, :enter => Proc.new{|b| BookingMailer.deliver_cancelled( b ); }, :after => Proc.new{|b| b.log( 'Booking status set to %s.' / b.current_status_to_human ) }
state :confirmed, :enter => Proc.new{|b| BookingMailer.deliver_confirmed( b ); }, :after => Proc.new{|b| b.log( 'Booking status set to %s.' / b.current_status_to_human ) }
state :service_rendered, :enter => Proc.new{|b| BookingMailer.deliver_feedback( b ); }, :after => Proc.new{|b| b.log( 'Booking status set to %s.' / b.current_status_to_human ) }
event( :pending ){ transitions :to => :pending, :from => :in_progress }
event( :awaiting_payment ){ transitions :to => :awaiting_payment, :from => [:pending, :in_review] }
event( :in_review ){ transitions :to => :in_review, :from => [:pending, :awaiting_payment] }
event( :cancelled ){ transitions :to => :cancelled, :from => [:pending, :awaiting_payment, :in_review] }
event( :confirmed ){ transitions :to => :confirmed, :from => :awaiting_payment, :guard => Proc.new{|b| !b.payment.nil? && b.payment.authorized? } }
event( :service_rendered ){ transitions :to => :service_rendered, :from => :confirmed }
class << self
def status_to_human( status = :pending ) status.to_s.sub('_',' ').upcase end
def running_total
self.find(:all, :conditions => ['bookings.status = ?','confirmed']).inject( Globalize::Currency.free ) { |total, booking| booking.total + total }
end
def total_by_status(state = :in_progress)
self.find_in_state(:all, state).inject( Globalize::Currency.free ) { |total, booking| booking.total + total }
end
def count_by_status(state = :in_progress) self.count_in_state(state) end
def most_recent_by_status(state = :pending)
self.find_in_state(:first, state, :order => 'bookings.created_at DESC')
end
end
def current_status_to_human; self.class.status_to_human( self.current_state() ) end
def log( event_description )
self.events.create!({ :account_id => self.account.id, :event_description => event_description })
end
def total
Globalize::Currency.new( ( self.booking_products.sum(:cents).to_i + self.booking_extras.sum(:cents).to_i ) ) - discount
end
def discount; (!self.coupon.nil? ? self.coupon.price : Globalize::Currency.free) end
def name; "[#{self.reference}] #{self.customer.name}" end
def editable?() [:in_progress, :in_review].include?( self.current_state ) end
def committed?() self.current_state != :in_progress end
def <=>(other_booking)
self.date.date_from <=> other_booking.date.date_from
end
end
The initial state
An initial status of In Progress, with all states stored in the status column
acts_as_state_machine :initial => :in_progress, :column => 'status'
Individual state declarations
The state definition accepts a symbol of the required state and a state callback Hash with the following allowed keys:
{ :enter => Proc.new{|record| record.do_something_when_we_enter_this_state! },
:after => Proc.new{|record| record.do_something_after_we_entered_this_state! },
:exit => Proc.new{|record| record.do_something_when_we_exit_this_state! } }
Generate a payment token when entering Awaiting Payment, deliver the payment request to the customer, log the status change for this booking.
state :awaiting_payment, :enter => Proc.new{|b| ( b.create_token({ :account_id => b.account.id, :booking_id => b.id }) if b.token.nil? ); BookingMailer.deliver_payment( b ); }, :after => Proc.new{|b| b.log( 'Status set to %s.' / b.current_status_to_human ) }
Event definitions
Declare the business logic required to manage the transitions between the required states.
A Booking may only be Confirmed once a payment method has been assigned AND authorized.
event( :confirmed ){ transitions :to => :confirmed, :from => :awaiting_payment, :guard => Proc.new{|b| !b.payment.nil? && b.payment.authorized? } }
An event accepts a symbol as the only argument ( :confirmed ).Express the transition within the block, with the following Hash keys allowed as arguments to the transitions singleton method:
{ :from => :the_intial_state,
:to => :the_target_state,
:guard => Proc.new{|record| record.condition_to_be_met_for_transition_to_occur? } }
Do note that the resulting event is a destructive action (modifies the receiver) and should be invoked with
booking.confirmed! #trailing !
Some monkey patching
Acts as State Machine is bundled with a nextstatesfor_event( event ) instance method.
Loading development environment.
>> b = Booking.find(1)
=> #"TransportBooking", "updated_at"=>"2006-11-17 11:47:09", "payable_id"=>"1", "account_id"=>"2", "cents"=>"10000", "type"=>"CreditCardPayment", "id"=>"1", "authorized"=>"1", "created_at"=>"2006-11-17 11:47:09"}>, @attributes={"status"=>"pending", "reference"=>"odhtnsghtp", "updated_at"=>"2006-11-17 11:47:07", "coupon_id"=>"1", "account_id"=>"2", "type"=>"TransportBooking", "id"=>"1", "user_id"=>"1", "customer_locale"=>"en", "created_at"=>"2006-11-17 11:47:07"}>
>> b.current_state
=> :pending
>> b.next_states_for_event(:awaiting_payment)
=> [#:awaiting_payment, :from=>:pending}, @from=:pending, @guard=nil>]
I needed the reverse, calculating the next valid events for the current state.
module ScottBarron
module Acts
module StateMachine
module InstanceMethods
def next_events_for_current_state
events = []
self.class.read_inheritable_attribute(:transition_table).each_value do |event|
event.each do |transition|
events << transition.to if transition.from == current_state()
end
end
events
end
end
end
end
end
Which is nice and DRY for use in your view (select box / radio group perhaps) to change the status for a Booking.
>> b.next_events_for_current_state
=> [:in_review, :awaiting_payment, :cancelled]
More examples
>> b.current_state
=> :pending
>> b.current_status_to_human
=> "PENDING"
>> b.next_events_for_current_state
=> [:in_review, :awaiting_payment, :cancelled]
>> b.in_review!
=> true
>> b.current_status_to_human
=> "IN REVIEW"
>> b.next_events_for_current_state
=> [:awaiting_payment, :cancelled]
>> b.awaiting_payment!
=> true
>> b.current_status_to_human
=> "AWAITING PAYMENT"
>> b.next_events_for_current_state
=> [:confirmed, :in_review, :cancelled]
>> b.cancelled!
=> true
>> b.current_status_to_human
=> "CANCELLED"
>> b.next_events_for_current_state
=> []
31 comments
Jump to comment form | comments rss [?]