module TaliaCore #:nodoc: module Workflow #:nodoc: class InvalidState < Exception #:nodoc: end class NoInitialState < Exception #:nodoc: end class NoAuthorizedException < Exception #:nodoc: end def self.included(base) #:nodoc: base.extend WorkflowMacro end module SupportingClasses class State attr_reader :name def initialize(name, opts) @name, @opts = name, opts end def entering(record) enteract = @opts[:enter] record.send(:run_transition_action, enteract) if enteract end def entered(record) afteractions = @opts[:after] return unless afteractions Array(afteractions).each do |afteract| record.send(:run_transition_action, afteract) end end def exited(record) exitact = @opts[:exit] record.send(:run_transition_action, exitact) if exitact end end class StateTransition attr_reader :from, :to, :opts def initialize(opts) @from, @to, @guard = opts[:from], opts[:to], opts[:guard] @opts = opts end def guard(obj, args) @guard ? obj.send(:run_transition_action, @guard, args) : true end def on_transition(obj, args = nil) action = @opts[:on_transition] obj.send(:run_transition_action, action, args) if action end def perform(record, args = nil) return false unless guard(record, args) loopback = record.current_state == to states = record.class.read_inheritable_attribute(:states) next_state = states[to] old_state = states[record.current_state] # permform action on_transition(record, args) 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 true end def ==(obj) @from == obj.from && @to == obj.to end end class Event attr_reader :name attr_reader :transitions attr_reader :opts def initialize(name, opts, transition_table, &block) @name = name.to_sym @transitions = transition_table[@name] = [] instance_eval(&block) if block @opts = opts @opts.freeze @transitions.freeze freeze end def next_states(record) @transitions.select { |t| t.from == record.current_state } end def fire(record, user, args = nil) # check user role unless @opts[:require_role].nil? raise NoAuthorizedException unless user.authorized_as?(@opts[:require_role]) end # perform transition next_states(record).each do |transition| break true if transition.perform(record, args) end end def transitions(trans_opts) Array(trans_opts[:from]).each do |s| @transitions << SupportingClasses::StateTransition.new(trans_opts.merge({:from => s.to_sym})) end end end end module WorkflowMacro # Configuration options are # # * +column+ - specifies the column name to use for keeping the state (default: state) # * +property+ - specifies the column name to use for keeping the property of current state (default: state_properties) # * +initial+ - specifies an initial state for newly created objects (required) def workflow_machine(opts) self.extend(ClassMethods) raise NoInitialState unless opts[:initial] write_inheritable_attribute :states, {} write_inheritable_attribute :initial_state, opts[:initial] write_inheritable_attribute :initial_state_properties, opts[:initial_properties] || {} write_inheritable_attribute :transition_table, {} write_inheritable_attribute :event_table, {} write_inheritable_attribute :state_column, opts[:column] || 'state' write_inheritable_attribute :state_properties_column, opts[:properties] || 'state_properties' class_inheritable_reader :initial_state class_inheritable_reader :initial_state_properties class_inheritable_reader :state_column class_inheritable_reader :state_properties_column class_inheritable_reader :transition_table class_inheritable_reader :event_table self.send(:include, TaliaCore::Workflow::InstanceMethods) before_create :set_initial_state, :set_initial_state_properties after_create :run_initial_state_actions end end module InstanceMethods def state_properties Marshal.load(self.send(self.class.state_properties_column)) end def state_properties=(value) write_attribute self.class.state_properties_column, Marshal.dump(value) end def set_initial_state #:nodoc: write_attribute self.class.state_column, self.class.initial_state.to_s end def set_initial_state_properties #:nodoc: write_attribute self.class.state_properties_column, Marshal.dump(self.class.initial_state_properties) end def run_initial_state_actions initial = self.class.read_inheritable_attribute(:states)[self.class.initial_state.to_sym] initial.entering(self) initial.entered(self) end # Returns the current state the object is in, as a Ruby symbol. def current_state self.send(self.class.state_column).to_sym end # Returns what the next state for a given event would be, as a Ruby symbol. def next_state_for_event(event) ns = next_states_for_event(event) ns.empty? ? nil : ns.first.to end def next_states_for_event(event) self.class.read_inheritable_attribute(:transition_table)[event.to_sym].select do |s| s.from == current_state end end def run_transition_action(action, args = nil) Symbol === action ? self.method(action).call(args) : action.call(self) end private :run_transition_action end module ClassMethods # Returns an array of all known states. def states read_inheritable_attribute(:states).keys end # Define an event. This takes a block which describes all valid transitions # for this event. # # Example: # # class Order < ActiveRecord::Base # acts_as_state_machine :initial => :open # # state :open # state :closed # # event :close_order do # transitions :to => :closed, :from => :open # end # end # # +transitions+ takes a hash where :to is the state to transition # to and :from is a state (or Array of states) from which this # event can be fired. # # This creates an instance method used for firing the event. The method # created is the name of the event followed by an exclamation point (!). # Example: order.close_order!. def event(event, opts={}, &block) tt = read_inheritable_attribute(:transition_table) et = read_inheritable_attribute(:event_table) e = et[event.to_sym] = SupportingClasses::Event.new(event, opts, tt, &block) define_method("#{event.to_s}!") { |user, *args| e.fire(self, user, args) } end # Define a state of the system. +state+ can take an optional Proc object # which will be executed every time the system transitions into that # state. The proc will be passed the current object. # # Example: # # class Order < ActiveRecord::Base # acts_as_state_machine :initial => :open # # state :open # state :closed, Proc.new { |o| Mailer.send_notice(o) } # end def state(name, opts={}) state = SupportingClasses::State.new(name.to_sym, opts) read_inheritable_attribute(:states)[name.to_sym] = state define_method("#{state.name}?") { current_state == state.name } end # Wraps ActiveRecord::Base.find to conveniently find all records in # a given state. Options: # # * +number+ - This is just :first or :all from ActiveRecord +find+ # * +state+ - The state to find # * +args+ - The rest of the args are passed down to ActiveRecord +find+ def find_in_state(number, state, *args) with_state_scope state do find(number, *args) end end # Wraps ActiveRecord::Base.count to conveniently count all records in # a given state. Options: # # * +state+ - The state to find # * +args+ - The rest of the args are passed down to ActiveRecord +find+ def count_in_state(state, *args) with_state_scope state do count(*args) end end # Wraps ActiveRecord::Base.calculate to conveniently calculate all records in # a given state. Options: # # * +state+ - The state to find # * +args+ - The rest of the args are passed down to ActiveRecord +calculate+ def calculate_in_state(state, *args) with_state_scope state do calculate(*args) end end protected def with_state_scope(state) raise InvalidState unless states.include?(state) with_scope :find => {:conditions => ["#{table_name}.#{state_column} = ?", state.to_s]} do yield if block_given? end end end end end