workflow-rails4 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,18 @@
1
+ module Workflow
2
+ class TransitionHalted < Exception
3
+
4
+ attr_reader :halted_because
5
+
6
+ def initialize(msg = nil)
7
+ @halted_because = msg
8
+ super msg
9
+ end
10
+
11
+ end
12
+
13
+ class NoTransitionAllowed < Exception; end
14
+
15
+ class WorkflowError < Exception; end
16
+
17
+ class WorkflowDefinitionError < Exception; end
18
+ end
@@ -0,0 +1,18 @@
1
+ module Workflow
2
+ class Event
3
+
4
+ attr_accessor :name, :transitions_to, :meta, :action
5
+
6
+ def initialize(name, transitions_to, meta = {}, &action)
7
+ @name, @transitions_to, @meta, @action = name, transitions_to.to_sym, meta, action
8
+ end
9
+
10
+ def draw(graph, from_state)
11
+ graph.add_edges(from_state.name.to_s, transitions_to.to_s, meta.merge(:label => to_s))
12
+ end
13
+
14
+ def to_s
15
+ @name.to_s
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,64 @@
1
+ require 'workflow/state'
2
+ require 'workflow/event'
3
+ require 'workflow/errors'
4
+
5
+ module Workflow
6
+ class Specification
7
+ attr_accessor :states, :initial_state, :meta,
8
+ :on_transition_proc, :before_transition_proc, :after_transition_proc, :on_error_proc
9
+
10
+ def initialize(meta = {}, &specification)
11
+ @states = Hash.new
12
+ @meta = meta
13
+ instance_eval(&specification)
14
+ end
15
+
16
+ def state_names
17
+ states.keys
18
+ end
19
+
20
+ private
21
+
22
+ def state(name, meta = {:meta => {}}, &events_and_etc)
23
+ # meta[:meta] to keep the API consistent..., gah
24
+ new_state = Workflow::State.new(name, self, meta[:meta])
25
+ @initial_state = new_state if @states.empty?
26
+ @states[name.to_sym] = new_state
27
+ @scoped_state = new_state
28
+ instance_eval(&events_and_etc) if events_and_etc
29
+ end
30
+
31
+ def event(name, args = {}, &action)
32
+ target = args[:transitions_to] || args[:transition_to]
33
+ raise WorkflowDefinitionError.new(
34
+ "missing ':transitions_to' in workflow event definition for '#{name}'") \
35
+ if target.nil?
36
+ @scoped_state.events[name.to_sym] =
37
+ Workflow::Event.new(name, target, (args[:meta] or {}), &action)
38
+ end
39
+
40
+ def on_entry(&proc)
41
+ @scoped_state.on_entry = proc
42
+ end
43
+
44
+ def on_exit(&proc)
45
+ @scoped_state.on_exit = proc
46
+ end
47
+
48
+ def after_transition(&proc)
49
+ @after_transition_proc = proc
50
+ end
51
+
52
+ def before_transition(&proc)
53
+ @before_transition_proc = proc
54
+ end
55
+
56
+ def on_transition(&proc)
57
+ @on_transition_proc = proc
58
+ end
59
+
60
+ def on_error(&proc)
61
+ @on_error_proc = proc
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,44 @@
1
+ module Workflow
2
+ class State
3
+ attr_accessor :name, :events, :meta, :on_entry, :on_exit
4
+ attr_reader :spec
5
+
6
+ def initialize(name, spec, meta = {})
7
+ @name, @spec, @events, @meta = name, spec, Hash.new, meta
8
+ end
9
+
10
+ def draw(graph)
11
+ defaults = {
12
+ :label => to_s,
13
+ :width => '1',
14
+ :height => '1',
15
+ :shape => 'ellipse'
16
+ }
17
+
18
+ node = graph.add_nodes(to_s, defaults.merge(meta))
19
+
20
+ # Add open arrow for initial state
21
+ # graph.add_edge(graph.add_node('starting_state', :shape => 'point'), node) if initial?
22
+
23
+ node
24
+ end
25
+
26
+
27
+ if RUBY_VERSION >= '1.9'
28
+ include Comparable
29
+ def <=>(other_state)
30
+ states = spec.states.keys
31
+ raise ArgumentError, "state `#{other_state}' does not exist" unless states.include?(other_state.to_sym)
32
+ states.index(self.to_sym) <=> states.index(other_state.to_sym)
33
+ end
34
+ end
35
+
36
+ def to_s
37
+ "#{name}"
38
+ end
39
+
40
+ def to_sym
41
+ name.to_sym
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,3 @@
1
+ module Workflow
2
+ VERSION = "1.1.0"
3
+ end
Binary file
@@ -0,0 +1,49 @@
1
+ require File.join(File.dirname(__FILE__), 'test_helper')
2
+
3
+ $VERBOSE = false
4
+ require 'active_record'
5
+ require 'sqlite3'
6
+ require 'workflow'
7
+
8
+ ActiveRecord::Migration.verbose = false
9
+
10
+ class Article < ActiveRecord::Base
11
+ include Workflow
12
+
13
+ workflow do
14
+ state :new
15
+ state :accepted
16
+ end
17
+ end
18
+
19
+ class ActiveRecordScopesTest < ActiveRecordTestCase
20
+
21
+ def setup
22
+ super
23
+
24
+ ActiveRecord::Schema.define do
25
+ create_table :articles do |t|
26
+ t.string :title
27
+ t.string :body
28
+ t.string :blame_reason
29
+ t.string :reject_reason
30
+ t.string :workflow_state
31
+ end
32
+ end
33
+ end
34
+
35
+ def assert_state(title, expected_state, klass = Order)
36
+ o = klass.find_by_title(title)
37
+ assert_equal expected_state, o.read_attribute(klass.workflow_column)
38
+ o
39
+ end
40
+
41
+ test 'have "with_new_state" scope' do
42
+ assert_respond_to Article, :with_new_state
43
+ end
44
+
45
+ test 'have "with_accepted_state" scope' do
46
+ assert_respond_to Article, :with_accepted_state
47
+ end
48
+ end
49
+
@@ -0,0 +1,82 @@
1
+ require File.join(File.dirname(__FILE__), 'test_helper')
2
+ require 'workflow'
3
+ class AdvanceExamplesTest < ActiveRecordTestCase
4
+
5
+ class Article
6
+ include Workflow
7
+ workflow do
8
+ state :new do
9
+ event :submit, :transitions_to => :awaiting_review
10
+ end
11
+ state :awaiting_review do
12
+ event :review, :transitions_to => :being_reviewed
13
+ end
14
+ state :being_reviewed do
15
+ event :accept, :transitions_to => :accepted
16
+ event :reject, :transitions_to => :rejected
17
+ end
18
+ state :accepted do
19
+ end
20
+ state :rejected do
21
+ end
22
+ end
23
+ end
24
+
25
+ test '#63 undoing event - automatically add revert events for every defined event' do
26
+ # also see https://github.com/geekq/workflow/issues/63
27
+ spec = Article.workflow_spec
28
+ spec.state_names.each do |state_name|
29
+ state = spec.states[state_name]
30
+
31
+ (state.events.values.reject {|e| e.name.to_s =~ /^revert_/ }).each do |event|
32
+ event_name = event.name
33
+ revert_event_name = "revert_" + event_name.to_s
34
+
35
+ # Add revert events
36
+ spec.states[event.transitions_to.to_sym].events[revert_event_name.to_sym] =
37
+ Workflow::Event.new(revert_event_name, state, {})
38
+
39
+ # Add methods for revert events
40
+ Article.module_eval do
41
+ define_method "#{revert_event_name}!".to_sym do |*args|
42
+ process_event!(revert_event_name, *args)
43
+ end
44
+ define_method "can_#{revert_event_name}?" do
45
+ return self.current_state.events.include?(revert_event_name)
46
+ end
47
+ end
48
+
49
+ end
50
+ end
51
+
52
+ a = Article.new
53
+ assert(a.new?, "should start with the 'new' state")
54
+ a.submit!
55
+ assert(a.awaiting_review?, "should now be in 'awaiting_review' state")
56
+ assert_equal(['revert_submit', 'review'], a.current_state.events.keys.map(&:to_s).sort)
57
+ a.revert_submit! # this method is added by our meta programming magic above
58
+ assert(a.new?, "should now be back in the 'new' state")
59
+ end
60
+
61
+ test '#92 Load workflow specification' do
62
+ c = Class.new
63
+ c.class_eval do
64
+ include Workflow
65
+ end
66
+
67
+ # build a Specification (you can load it from yaml file too)
68
+ myspec = Workflow::Specification.new do
69
+ state :one do
70
+ event :dynamic_transition, :transitions_to => :one_a
71
+ end
72
+ state :one_a
73
+ end
74
+
75
+ c.send :assign_workflow, myspec
76
+
77
+ a = c.new
78
+ a.dynamic_transition!(1)
79
+ assert a.one_a?, 'Expected successful transition to a new state'
80
+ end
81
+
82
+ end
@@ -0,0 +1,119 @@
1
+ require File.join(File.dirname(__FILE__), 'test_helper')
2
+
3
+ $VERBOSE = false
4
+ require 'active_record'
5
+ require 'sqlite3'
6
+ require 'workflow'
7
+
8
+ ActiveRecord::Migration.verbose = false
9
+
10
+ # Transition based validation
11
+ # ---------------------------
12
+ # If you are using ActiveRecord you might want to define different validations
13
+ # for different transitions. There is a `validates_presence_of` hook that let's
14
+ # you specify the attributes that need to be present for an successful transition.
15
+ # If the object is not valid at the end of the transition event the transition
16
+ # is halted and a TransitionHalted exception is thrown.
17
+ #
18
+ # Here is a sample that illustrates how to use the presence validation:
19
+ # (use case suggested by http://github.com/southdesign)
20
+ class Article < ActiveRecord::Base
21
+ include Workflow
22
+ workflow do
23
+ state :new do
24
+ event :accept, :transitions_to => :accepted, :meta => {:validates_presence_of => [:title, :body]}
25
+ event :reject, :transitions_to => :rejected
26
+ end
27
+ state :accepted do
28
+ event :blame, :transitions_to => :blamed, :meta => {:validates_presence_of => [:title, :body, :blame_reason]}
29
+ event :delete, :transitions_to => :deleted
30
+ end
31
+ state :rejected do
32
+ event :delete, :transitions_to => :deleted
33
+ end
34
+ state :blamed do
35
+ event :delete, :transitions_to => :deleted
36
+ end
37
+ state :deleted do
38
+ event :accept, :transitions_to => :accepted
39
+ end
40
+
41
+ on_transition do |from, to, triggering_event, *event_args|
42
+ if self.class.superclass.to_s.split("::").first == "ActiveRecord"
43
+ singleton = class << self; self end
44
+ validations = Proc.new {}
45
+
46
+ meta = Article.workflow_spec.states[from].events[triggering_event].meta
47
+ fields_to_validate = meta[:validates_presence_of]
48
+ if fields_to_validate
49
+ validations = Proc.new {
50
+ errors.add_on_blank(fields_to_validate) if fields_to_validate
51
+ }
52
+ end
53
+
54
+ singleton.send :define_method, :validate_for_transition, &validations
55
+ validate_for_transition
56
+ halt! "Event[#{triggering_event}]'s transitions_to[#{to}] is not valid." unless self.errors.empty?
57
+ end
58
+ end
59
+ end
60
+ end
61
+
62
+ class AdvancedHooksAndValidationTest < ActiveRecordTestCase
63
+
64
+ def setup
65
+ super
66
+
67
+ ActiveRecord::Schema.define do
68
+ create_table :articles do |t|
69
+ t.string :title
70
+ t.string :body
71
+ t.string :blame_reason
72
+ t.string :reject_reason
73
+ t.string :workflow_state
74
+ end
75
+ end
76
+
77
+ exec "INSERT INTO articles(title, body, blame_reason, reject_reason, workflow_state) VALUES('new1', NULL, NULL, NULL, 'new')"
78
+ exec "INSERT INTO articles(title, body, blame_reason, reject_reason, workflow_state) VALUES('new2', 'some content', NULL, NULL, 'new')"
79
+ exec "INSERT INTO articles(title, body, blame_reason, reject_reason, workflow_state) VALUES('accepted1', 'some content', NULL, NULL, 'accepted')"
80
+
81
+ end
82
+
83
+ def assert_state(title, expected_state, klass = Order)
84
+ o = klass.find_by_title(title)
85
+ assert_equal expected_state, o.read_attribute(klass.workflow_column)
86
+ o
87
+ end
88
+
89
+ test 'deny transition from new to accepted because of the missing presence of the body' do
90
+ a = Article.find_by_title('new1');
91
+ assert_raise Workflow::TransitionHalted do
92
+ a.accept!
93
+ end
94
+ assert_state 'new1', 'new', Article
95
+ end
96
+
97
+ test 'allow transition from new to accepted because body is present this time' do
98
+ a = Article.find_by_title('new2');
99
+ assert a.accept!
100
+ assert_state 'new2', 'accepted', Article
101
+ end
102
+
103
+ test 'allow transition from accepted to blamed because of a blame_reason' do
104
+ a = Article.find_by_title('accepted1');
105
+ a.blame_reason = "Provocant thesis"
106
+ assert a.blame!
107
+ assert_state 'accepted1', 'blamed', Article
108
+ end
109
+
110
+ test 'deny transition from accepted to blamed because of no blame_reason' do
111
+ a = Article.find_by_title('accepted1');
112
+ assert_raise Workflow::TransitionHalted do
113
+ assert a.blame!
114
+ end
115
+ assert_state 'accepted1', 'accepted', Article
116
+ end
117
+
118
+ end
119
+
@@ -0,0 +1,107 @@
1
+ require 'test_helper'
2
+
3
+ $VERBOSE = false
4
+ require 'active_record'
5
+ require 'logger'
6
+ require 'sqlite3'
7
+ require 'workflow'
8
+ require 'mocha/setup'
9
+ require 'stringio'
10
+ require 'protected_attributes' if ActiveRecord::VERSION::MAJOR >= 4
11
+
12
+ ActiveRecord::Migration.verbose = false
13
+
14
+ class AttrProtectedTestOrder < ActiveRecord::Base
15
+ include Workflow
16
+
17
+ workflow do
18
+ state :submitted do
19
+ event :accept, :transitions_to => :accepted, :meta => {:doc_weight => 8} do |reviewer, args|
20
+ end
21
+ end
22
+ state :accepted do
23
+ event :ship, :transitions_to => :shipped
24
+ end
25
+ state :shipped
26
+ end
27
+
28
+ attr_accessible :title # protecting all the other attributes
29
+
30
+ end
31
+
32
+ AttrProtectedTestOrder.logger = Logger.new(STDOUT) # active_record 2.3 expects a logger instance
33
+ AttrProtectedTestOrder.logger.level = Logger::WARN # switch to Logger::DEBUG to see the SQL statements
34
+
35
+ class AttrProtectedTest < ActiveRecordTestCase
36
+
37
+ def setup
38
+ super
39
+
40
+ ActiveRecord::Schema.define do
41
+ create_table :attr_protected_test_orders do |t|
42
+ t.string :title, :null => false
43
+ t.string :workflow_state
44
+ end
45
+ end
46
+
47
+ exec "INSERT INTO attr_protected_test_orders(title, workflow_state) VALUES('order1', 'submitted')"
48
+ exec "INSERT INTO attr_protected_test_orders(title, workflow_state) VALUES('order2', 'accepted')"
49
+ exec "INSERT INTO attr_protected_test_orders(title, workflow_state) VALUES('order3', 'accepted')"
50
+ exec "INSERT INTO attr_protected_test_orders(title, workflow_state) VALUES('order4', 'accepted')"
51
+ exec "INSERT INTO attr_protected_test_orders(title, workflow_state) VALUES('order5', 'accepted')"
52
+ exec "INSERT INTO attr_protected_test_orders(title, workflow_state) VALUES('protected order', 'submitted')"
53
+ end
54
+
55
+ def assert_state(title, expected_state, klass = AttrProtectedTestOrder)
56
+ o = klass.find_by_title(title)
57
+ assert_equal expected_state, o.read_attribute(klass.workflow_column)
58
+ o
59
+ end
60
+
61
+ test 'cannot mass-assign workflow_state if attr_protected' do
62
+ o = AttrProtectedTestOrder.find_by_title('order1')
63
+ assert_equal 'submitted', o.read_attribute(:workflow_state)
64
+ AttrProtectedTestOrder.logger.level = Logger::ERROR # ignore warnings
65
+ o.update_attributes :workflow_state => 'some_bad_value'
66
+ AttrProtectedTestOrder.logger.level = Logger::WARN
67
+ assert_equal 'submitted', o.read_attribute(:workflow_state)
68
+ o.update_attribute :workflow_state, 'some_overridden_value'
69
+ assert_equal 'some_overridden_value', o.read_attribute(:workflow_state)
70
+ end
71
+
72
+ test 'immediately save the new workflow_state on state machine transition' do
73
+ o = assert_state 'order2', 'accepted'
74
+ assert o.ship!
75
+ assert_state 'order2', 'shipped'
76
+ end
77
+
78
+ test 'persist workflow_state in the db and reload' do
79
+ o = assert_state 'order3', 'accepted'
80
+ assert_equal :accepted, o.current_state.name
81
+ o.ship! # should save in the database, no `o.save!` needed
82
+
83
+ assert_state 'order3', 'shipped'
84
+
85
+ o.reload
86
+ assert_equal 'shipped', o.read_attribute(:workflow_state)
87
+ end
88
+
89
+ test 'default workflow column should be workflow_state' do
90
+ o = assert_state 'order4', 'accepted'
91
+ assert_equal :workflow_state, o.class.workflow_column
92
+ end
93
+
94
+ test 'access workflow specification' do
95
+ assert_equal 3, AttrProtectedTestOrder.workflow_spec.states.length
96
+ assert_equal ['submitted', 'accepted', 'shipped'].sort,
97
+ AttrProtectedTestOrder.workflow_spec.state_names.map{|n| n.to_s}.sort
98
+ end
99
+
100
+ test 'current state object' do
101
+ o = assert_state 'order5', 'accepted'
102
+ assert_equal 'accepted', o.current_state.to_s
103
+ assert_equal 1, o.current_state.events.length
104
+ end
105
+
106
+ end
107
+