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,31 @@
1
+ require 'rubygems'
2
+ require 'rake/testtask'
3
+ require 'rdoc/task'
4
+
5
+ require 'bundler'
6
+ Bundler.setup
7
+ Bundler::GemHelper.install_tasks
8
+
9
+ task :default => [:test]
10
+
11
+ require 'rake'
12
+ Rake::TestTask.new do |t|
13
+ t.libs << 'test'
14
+ t.verbose = true
15
+ t.warning = true
16
+ t.test_files = FileList['test/*_test.rb'] + FileList['test/new_versions/*_test.rb']
17
+ end
18
+
19
+ Rake::TestTask.new do |t|
20
+ t.name = 'test_without_new_versions'
21
+ t.libs << 'test'
22
+ t.verbose = true
23
+ t.warning = true
24
+ t.pattern = 'test/*_test.rb'
25
+ end
26
+
27
+ Rake::RDocTask.new do |rdoc|
28
+ rdoc.rdoc_files.include("lib/**/*.rb")
29
+ rdoc.options << "-S"
30
+ end
31
+
@@ -0,0 +1,11 @@
1
+ source "http://rubygems.org"
2
+
3
+ group :development do
4
+ gem "rdoc", ">= 3.12"
5
+ gem "bundler", ">= 1.0.0"
6
+ gem "activerecord", "~>2.3.15"
7
+ gem "sqlite3"
8
+ gem "mocha"
9
+ gem "rake"
10
+ gem "ruby-graphviz", ">= 1.0"
11
+ end
@@ -0,0 +1,11 @@
1
+ source "http://rubygems.org"
2
+
3
+ group :development do
4
+ gem "rdoc", ">= 3.12"
5
+ gem "bundler", ">= 1.0.0"
6
+ gem "activerecord", "~>3.2"
7
+ gem "sqlite3"
8
+ gem "mocha"
9
+ gem "rake"
10
+ gem "ruby-graphviz", ">= 1.0"
11
+ end
@@ -0,0 +1,12 @@
1
+ source "http://rubygems.org"
2
+
3
+ group :development do
4
+ gem "rdoc", ">= 3.12"
5
+ gem "bundler", ">= 1.0.0"
6
+ gem "activerecord"
7
+ gem "sqlite3"
8
+ gem "mocha"
9
+ gem "rake"
10
+ gem "ruby-graphviz", ">= 1.0"
11
+ gem 'protected_attributes'
12
+ end
@@ -0,0 +1,277 @@
1
+ require 'rubygems'
2
+
3
+ require 'workflow/specification'
4
+ require 'workflow/adapters/active_record'
5
+ require 'workflow/adapters/remodel'
6
+
7
+ # See also README.markdown for documentation
8
+ module Workflow
9
+ module ClassMethods
10
+ attr_reader :workflow_spec
11
+
12
+ def workflow_column(column_name=nil)
13
+ if column_name
14
+ @workflow_state_column_name = column_name.to_sym
15
+ end
16
+ if !@workflow_state_column_name && superclass.respond_to?(:workflow_column)
17
+ @workflow_state_column_name = superclass.workflow_column
18
+ end
19
+ @workflow_state_column_name ||= :workflow_state
20
+ end
21
+
22
+ def workflow(&specification)
23
+ assign_workflow Specification.new(Hash.new, &specification)
24
+ end
25
+
26
+ private
27
+
28
+ # Creates the convinience methods like `my_transition!`
29
+ def assign_workflow(specification_object)
30
+
31
+ # Merging two workflow specifications can **not** be done automically, so
32
+ # just make the latest specification win. Same for inheritance -
33
+ # definition in the subclass wins.
34
+ if respond_to? :inherited_workflow_spec # undefine methods defined by the old workflow_spec
35
+ inherited_workflow_spec.states.values.each do |state|
36
+ state_name = state.name
37
+ module_eval do
38
+ undef_method "#{state_name}?"
39
+ end
40
+
41
+ state.events.values.each do |event|
42
+ event_name = event.name
43
+ module_eval do
44
+ undef_method "#{event_name}!".to_sym
45
+ undef_method "can_#{event_name}?"
46
+ end
47
+ end
48
+ end
49
+ end
50
+
51
+ @workflow_spec = specification_object
52
+ @workflow_spec.states.values.each do |state|
53
+ state_name = state.name
54
+ module_eval do
55
+ define_method "#{state_name}?" do
56
+ state_name == current_state.name
57
+ end
58
+ end
59
+
60
+ state.events.values.each do |event|
61
+ event_name = event.name
62
+ module_eval do
63
+ define_method "#{event_name}!".to_sym do |*args|
64
+ process_event!(event_name, *args)
65
+ end
66
+
67
+ define_method "can_#{event_name}?" do
68
+ return self.current_state.events.include?(event_name)
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+
76
+ module InstanceMethods
77
+
78
+ def current_state
79
+ loaded_state = load_workflow_state
80
+ res = spec.states[loaded_state.to_sym] if loaded_state
81
+ res || spec.initial_state
82
+ end
83
+
84
+ # See the 'Guards' section in the README
85
+ # @return true if the last transition was halted by one of the transition callbacks.
86
+ def halted?
87
+ @halted
88
+ end
89
+
90
+ # @return the reason of the last transition abort as set by the previous
91
+ # call of `halt` or `halt!` method.
92
+ def halted_because
93
+ @halted_because
94
+ end
95
+
96
+ def process_event!(name, *args)
97
+ event = current_state.events[name.to_sym]
98
+ raise NoTransitionAllowed.new(
99
+ "There is no event #{name.to_sym} defined for the #{current_state} state") \
100
+ if event.nil?
101
+ @halted_because = nil
102
+ @halted = false
103
+
104
+ check_transition(event)
105
+
106
+ from = current_state
107
+ to = spec.states[event.transitions_to]
108
+
109
+ run_before_transition(from, to, name, *args)
110
+ return false if @halted
111
+
112
+ begin
113
+ return_value = run_action(event.action, *args) || run_action_callback(event.name, *args)
114
+ rescue Exception => e
115
+ run_on_error(e, from, to, name, *args)
116
+ end
117
+
118
+ return false if @halted
119
+
120
+ run_on_transition(from, to, name, *args)
121
+
122
+ run_on_exit(from, to, name, *args)
123
+
124
+ transition_value = persist_workflow_state to.to_s
125
+
126
+ run_on_entry(to, from, name, *args)
127
+
128
+ run_after_transition(from, to, name, *args)
129
+
130
+ return_value.nil? ? transition_value : return_value
131
+ end
132
+
133
+ def halt(reason = nil)
134
+ @halted_because = reason
135
+ @halted = true
136
+ end
137
+
138
+ def halt!(reason = nil)
139
+ @halted_because = reason
140
+ @halted = true
141
+ raise TransitionHalted.new(reason)
142
+ end
143
+
144
+ def spec
145
+ # check the singleton class first
146
+ class << self
147
+ return workflow_spec if workflow_spec
148
+ end
149
+
150
+ c = self.class
151
+ # using a simple loop instead of class_inheritable_accessor to avoid
152
+ # dependency on Rails' ActiveSupport
153
+ until c.workflow_spec || !(c.include? Workflow)
154
+ c = c.superclass
155
+ end
156
+ c.workflow_spec
157
+ end
158
+
159
+ private
160
+
161
+ def check_transition(event)
162
+ # Create a meaningful error message instead of
163
+ # "undefined method `on_entry' for nil:NilClass"
164
+ # Reported by Kyle Burton
165
+ if !spec.states[event.transitions_to]
166
+ raise WorkflowError.new("Event[#{event.name}]'s " +
167
+ "transitions_to[#{event.transitions_to}] is not a declared state.")
168
+ end
169
+ end
170
+
171
+ def run_before_transition(from, to, event, *args)
172
+ instance_exec(from.name, to.name, event, *args, &spec.before_transition_proc) if
173
+ spec.before_transition_proc
174
+ end
175
+
176
+ def run_on_error(error, from, to, event, *args)
177
+ if spec.on_error_proc
178
+ instance_exec(error, from.name, to.name, event, *args, &spec.on_error_proc)
179
+ halt(error.message)
180
+ else
181
+ raise error
182
+ end
183
+ end
184
+
185
+ def run_on_transition(from, to, event, *args)
186
+ instance_exec(from.name, to.name, event, *args, &spec.on_transition_proc) if spec.on_transition_proc
187
+ end
188
+
189
+ def run_after_transition(from, to, event, *args)
190
+ instance_exec(from.name, to.name, event, *args, &spec.after_transition_proc) if
191
+ spec.after_transition_proc
192
+ end
193
+
194
+ def run_action(action, *args)
195
+ instance_exec(*args, &action) if action
196
+ end
197
+
198
+ def has_callback?(action)
199
+ # 1. public callback method or
200
+ # 2. protected method somewhere in the class hierarchy or
201
+ # 3. private in the immediate class (parent classes ignored)
202
+ self.respond_to?(action) or
203
+ self.class.protected_method_defined?(action) or
204
+ self.private_methods(false).map(&:to_sym).include?(action)
205
+ end
206
+
207
+ def run_action_callback(action_name, *args)
208
+ action = action_name.to_sym
209
+ self.send(action, *args) if has_callback?(action)
210
+ end
211
+
212
+ def run_on_entry(state, prior_state, triggering_event, *args)
213
+ if state.on_entry
214
+ instance_exec(prior_state.name, triggering_event, *args, &state.on_entry)
215
+ else
216
+ hook_name = "on_#{state}_entry"
217
+ self.send hook_name, prior_state, triggering_event, *args if self.respond_to? hook_name
218
+ end
219
+ end
220
+
221
+ def run_on_exit(state, new_state, triggering_event, *args)
222
+ if state
223
+ if state.on_exit
224
+ instance_exec(new_state.name, triggering_event, *args, &state.on_exit)
225
+ else
226
+ hook_name = "on_#{state}_exit"
227
+ self.send hook_name, new_state, triggering_event, *args if self.respond_to? hook_name
228
+ end
229
+ end
230
+ end
231
+
232
+ # load_workflow_state and persist_workflow_state
233
+ # can be overriden to handle the persistence of the workflow state.
234
+ #
235
+ # Default (non ActiveRecord) implementation stores the current state
236
+ # in a variable.
237
+ #
238
+ # Default ActiveRecord implementation uses a 'workflow_state' database column.
239
+ def load_workflow_state
240
+ @workflow_state if instance_variable_defined? :@workflow_state
241
+ end
242
+
243
+ def persist_workflow_state(new_value)
244
+ @workflow_state = new_value
245
+ end
246
+ end
247
+
248
+ def self.included(klass)
249
+ klass.send :include, InstanceMethods
250
+
251
+ # backup the parent workflow spec, making accessible through #inherited_workflow_spec
252
+ if klass.superclass.respond_to?(:workflow_spec, true)
253
+ klass.module_eval do
254
+ # see http://stackoverflow.com/a/2495650/111995 for implementation explanation
255
+ pro = Proc.new { klass.superclass.workflow_spec }
256
+ singleton_class = class << self; self; end
257
+ singleton_class.send(:define_method, :inherited_workflow_spec) do
258
+ pro.call
259
+ end
260
+ end
261
+ end
262
+
263
+ klass.extend ClassMethods
264
+
265
+ if Object.const_defined?(:ActiveRecord)
266
+ if klass < ActiveRecord::Base
267
+ klass.send :include, Adapter::ActiveRecord::InstanceMethods
268
+ klass.send :extend, Adapter::ActiveRecord::Scopes
269
+ klass.before_validation :write_initial_state
270
+ end
271
+ elsif Object.const_defined?(:Remodel)
272
+ if klass < Adapter::Remodel::Entity
273
+ klass.send :include, Remodel::InstanceMethods
274
+ end
275
+ end
276
+ end
277
+ end
@@ -0,0 +1,66 @@
1
+ module Workflow
2
+ module Adapter
3
+ module ActiveRecord
4
+ module InstanceMethods
5
+ def load_workflow_state
6
+ read_attribute(self.class.workflow_column)
7
+ end
8
+
9
+ # On transition the new workflow state is immediately saved in the
10
+ # database.
11
+ def persist_workflow_state(new_value)
12
+ if self.respond_to? :update_column
13
+ # Rails 3.1 or newer
14
+ update_column self.class.workflow_column, new_value
15
+ else
16
+ # older Rails; beware of side effect: other (pending) attribute changes will be persisted too
17
+ update_attribute self.class.workflow_column, new_value
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ # Motivation: even if NULL is stored in the workflow_state database column,
24
+ # the current_state is correctly recognized in the Ruby code. The problem
25
+ # arises when you want to SELECT records filtering by the value of initial
26
+ # state. That's why it is important to save the string with the name of the
27
+ # initial state in all the new records.
28
+ def write_initial_state
29
+ write_attribute self.class.workflow_column, current_state.to_s
30
+ end
31
+ end
32
+
33
+ # This module will automatically generate ActiveRecord scopes based on workflow states.
34
+ # The name of each generated scope will be something like `with_<state_name>_state`
35
+ #
36
+ # Examples:
37
+ #
38
+ # Article.with_pending_state # => ActiveRecord::Relation
39
+ #
40
+ # Example above just adds `where(:state_column_name => 'pending')` to AR query and returns
41
+ # ActiveRecord::Relation.
42
+ module Scopes
43
+ def self.extended(object)
44
+ class << object
45
+ alias_method :workflow_without_scopes, :workflow unless method_defined?(:workflow_without_scopes)
46
+ alias_method :workflow, :workflow_with_scopes
47
+ end
48
+ end
49
+
50
+ def workflow_with_scopes(&specification)
51
+ workflow_without_scopes(&specification)
52
+ states = workflow_spec.states.values
53
+ eigenclass = class << self; self; end
54
+
55
+ states.each do |state|
56
+ # Use eigenclass instead of `define_singleton_method`
57
+ # to be compatible with Ruby 1.8+
58
+ eigenclass.send(:define_method, "with_#{state}_state") do
59
+ where("#{table_name}.#{self.workflow_column.to_sym} = ?", state.to_s)
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,15 @@
1
+ module Workflow
2
+ module Adapter
3
+ module Remodel
4
+ module InstanceMethods
5
+ def load_workflow_state
6
+ send(self.class.workflow_column)
7
+ end
8
+
9
+ def persist_workflow_state(new_value)
10
+ update(self.class.workflow_column => new_value)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,79 @@
1
+ begin
2
+ require 'rubygems'
3
+
4
+ gem 'ruby-graphviz', '>=1.0'
5
+ gem 'activesupport'
6
+
7
+ require 'graphviz'
8
+ require 'active_support/inflector'
9
+ rescue LoadError => e
10
+ $stderr.puts "Could not load the ruby-graphiz or active_support gems for rendering: #{e.message}"
11
+ end
12
+
13
+ module Workflow
14
+ module Draw
15
+
16
+ # Generates a `dot` graph of the workflow.
17
+ # Prerequisite: the `dot` binary. (Download from http://www.graphviz.org/)
18
+ # You can use this method in your own Rakefile like this:
19
+ #
20
+ # namespace :doc do
21
+ # desc "Generate a workflow graph for a model passed e.g. as 'MODEL=Order'."
22
+ # task :workflow => :environment do
23
+ # require 'workflow/draw'
24
+ # Workflow::Draw::workflow_diagram(ENV['MODEL'].constantize)
25
+ # end
26
+ # end
27
+ #
28
+ # You can influence the placement of nodes by specifying
29
+ # additional meta information in your states and transition descriptions.
30
+ # You can assign higher `weight` value to the typical transitions
31
+ # in your workflow. All other states and transitions will be arranged
32
+ # around that main line. See also `weight` in the graphviz documentation.
33
+ # Example:
34
+ #
35
+ # state :new do
36
+ # event :approve, :transitions_to => :approved, :meta => {:weight => 8}
37
+ # end
38
+ #
39
+ #
40
+ # @param klass A class with the Workflow mixin, for which you wish the graphical workflow representation
41
+ # @param [String] target_dir Directory, where to save the dot and the pdf files
42
+ # @param [String] graph_options You can change graph orientation, size etc. See graphviz documentation
43
+ def self.workflow_diagram(klass, options={})
44
+ options = {
45
+ :name => "#{klass.name.tableize}_workflow".gsub('/', '_'),
46
+ :path => '.',
47
+ :orientation => "landscape",
48
+ :ratio => "fill",
49
+ :format => 'png',
50
+ :font => 'Helvetica'
51
+ }.merge options
52
+
53
+ graph = ::GraphViz.new('G', :rankdir => options[:orientation] == 'landscape' ? 'LR' : 'TB', :ratio => options[:ratio])
54
+
55
+ # Add nodes
56
+ klass.workflow_spec.states.each do |_, state|
57
+ node = state.draw(graph)
58
+ node.fontname = options[:font]
59
+
60
+ state.events.each do |_, event|
61
+ edge = event.draw(graph, state)
62
+ edge.fontname = options[:font]
63
+ end
64
+ end
65
+
66
+ # Generate the graph
67
+ filename = File.join(options[:path], "#{options[:name]}.#{options[:format]}")
68
+
69
+ graph.output options[:format] => "'#{filename}'"
70
+
71
+ puts "
72
+ Please run the following to open the generated file:
73
+
74
+ open '#{filename}'
75
+ "
76
+ graph
77
+ end
78
+ end
79
+ end