signoff 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +79 -0
- data/LICENSE.txt +21 -0
- data/README.md +794 -0
- data/app/models/signoff/event.rb +38 -0
- data/lib/generators/signoff/install/USAGE +13 -0
- data/lib/generators/signoff/install/install_generator.rb +56 -0
- data/lib/generators/signoff/install/templates/initializer.rb +30 -0
- data/lib/generators/signoff/install/templates/migration.rb.tt +32 -0
- data/lib/generators/signoff/model/USAGE +11 -0
- data/lib/generators/signoff/model/model_generator.rb +50 -0
- data/lib/generators/signoff/model/templates/migration.rb.tt +9 -0
- data/lib/signoff/configuration.rb +69 -0
- data/lib/signoff/controller.rb +35 -0
- data/lib/signoff/current.rb +18 -0
- data/lib/signoff/definition.rb +159 -0
- data/lib/signoff/dsl.rb +90 -0
- data/lib/signoff/engine.rb +19 -0
- data/lib/signoff/errors.rb +29 -0
- data/lib/signoff/model.rb +424 -0
- data/lib/signoff/version.rb +5 -0
- data/lib/signoff.rb +65 -0
- metadata +143 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Signoff
|
|
4
|
+
# The immutable audit record written for every workflow transition.
|
|
5
|
+
#
|
|
6
|
+
# Columns: workflowable (polymorphic), user_id, action, from_state, to_state,
|
|
7
|
+
# comment, metadata (jsonb), ip_address, user_agent, created_at.
|
|
8
|
+
class Event < ActiveRecord::Base
|
|
9
|
+
self.table_name = Signoff.configuration.event_table_name
|
|
10
|
+
|
|
11
|
+
# Explicitly required (independent of the host's
|
|
12
|
+
# +belongs_to_required_by_default+ setting) so every audit row is anchored
|
|
13
|
+
# to its subject.
|
|
14
|
+
belongs_to :workflowable, polymorphic: true,
|
|
15
|
+
inverse_of: :signoff_events,
|
|
16
|
+
optional: false
|
|
17
|
+
belongs_to :user,
|
|
18
|
+
class_name: (Signoff.configuration.user_class || "User").to_s,
|
|
19
|
+
optional: true
|
|
20
|
+
|
|
21
|
+
validates :action, presence: true
|
|
22
|
+
validates :to_state, presence: true
|
|
23
|
+
|
|
24
|
+
scope :chronological, -> { order(created_at: :asc, id: :asc) }
|
|
25
|
+
scope :recent, -> { order(created_at: :desc, id: :desc) }
|
|
26
|
+
scope :with_action, ->(action) { where(action: action.to_s) }
|
|
27
|
+
scope :approvals, -> { with_action("approve") }
|
|
28
|
+
scope :rejections, -> { with_action("reject") }
|
|
29
|
+
|
|
30
|
+
# Audit rows are append-only. Once persisted they cannot be modified or
|
|
31
|
+
# destroyed through ActiveRecord unless +config.immutable_events+ is false.
|
|
32
|
+
def readonly?
|
|
33
|
+
return super unless Signoff.configuration.immutable_events
|
|
34
|
+
|
|
35
|
+
persisted? || super
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Installs Signoff. Creates the immutable audit events table
|
|
3
|
+
migration (PostgreSQL JSONB) and a configuration initializer.
|
|
4
|
+
|
|
5
|
+
Example:
|
|
6
|
+
rails generate signoff:install
|
|
7
|
+
|
|
8
|
+
This will create:
|
|
9
|
+
config/initializers/signoff.rb
|
|
10
|
+
db/migrate/XXXXXXXXXXXXXX_create_signoff_events.rb
|
|
11
|
+
|
|
12
|
+
Options:
|
|
13
|
+
--table-name=signoff_events
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module Signoff
|
|
7
|
+
module Generators
|
|
8
|
+
# Generates the events table migration and the configuration initializer.
|
|
9
|
+
#
|
|
10
|
+
# rails generate signoff:install
|
|
11
|
+
class InstallGenerator < Rails::Generators::Base
|
|
12
|
+
include ActiveRecord::Generators::Migration
|
|
13
|
+
|
|
14
|
+
source_root File.expand_path("templates", __dir__)
|
|
15
|
+
|
|
16
|
+
desc "Creates the signoff events migration and initializer."
|
|
17
|
+
|
|
18
|
+
class_option :table_name, type: :string,
|
|
19
|
+
default: "signoff_events",
|
|
20
|
+
desc: "Name of the events table"
|
|
21
|
+
class_option :skip_metadata_index, type: :boolean, default: false,
|
|
22
|
+
desc: "Skip the GIN index on the metadata column"
|
|
23
|
+
|
|
24
|
+
def create_initializer
|
|
25
|
+
template "initializer.rb", "config/initializers/signoff.rb"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def create_migration_file
|
|
29
|
+
migration_template(
|
|
30
|
+
"migration.rb.tt",
|
|
31
|
+
File.join(db_migrate_path, "create_signoff_events.rb")
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def display_post_install_message
|
|
36
|
+
say ""
|
|
37
|
+
say "signoff installed!", :green
|
|
38
|
+
say " 1. Review config/initializers/signoff.rb"
|
|
39
|
+
say " 2. Run: rails db:migrate"
|
|
40
|
+
say " 3. Add a state column to each workflow model, e.g.:"
|
|
41
|
+
say " rails g signoff:model ExpenseReport"
|
|
42
|
+
say ""
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def table_name
|
|
48
|
+
options[:table_name]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def migration_version
|
|
52
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Signoff.configure do |config|
|
|
4
|
+
# The model that represents the acting user (referenced by Event#user).
|
|
5
|
+
config.user_class = "User"
|
|
6
|
+
|
|
7
|
+
# Persist the request IP address on each event. Populate
|
|
8
|
+
# Signoff::Current.ip_address (e.g. by including
|
|
9
|
+
# Signoff::Controller in ApplicationController) for this to take
|
|
10
|
+
# effect.
|
|
11
|
+
config.track_ip_addresses = false
|
|
12
|
+
|
|
13
|
+
# Persist the request User-Agent on each event.
|
|
14
|
+
config.store_user_agent = false
|
|
15
|
+
|
|
16
|
+
# Default column used to store the current state on workflowable models.
|
|
17
|
+
# Override per-model with `signoff(column: :workflow_state)`.
|
|
18
|
+
config.default_state_column = :approval_state
|
|
19
|
+
|
|
20
|
+
# Validate the whole record before writing the state column on a transition.
|
|
21
|
+
# Leave false so unrelated validation errors never block a transition.
|
|
22
|
+
config.validate_on_transition = false
|
|
23
|
+
|
|
24
|
+
# :dependent strategy for the events association. Audit rows are immutable by
|
|
25
|
+
# default, so use :delete_all or :nullify (not :destroy).
|
|
26
|
+
config.dependent = :delete_all
|
|
27
|
+
|
|
28
|
+
# Set to false to allow events to be updated/destroyed through ActiveRecord.
|
|
29
|
+
# config.immutable_events = true
|
|
30
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateSignoffEvents < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
def change
|
|
5
|
+
create_table :<%= table_name %> do |t|
|
|
6
|
+
t.string :workflowable_type, null: false
|
|
7
|
+
t.bigint :workflowable_id, null: false
|
|
8
|
+
t.bigint :user_id
|
|
9
|
+
t.string :action, null: false
|
|
10
|
+
t.string :from_state
|
|
11
|
+
t.string :to_state, null: false
|
|
12
|
+
t.text :comment
|
|
13
|
+
t.jsonb :metadata, null: false, default: {}
|
|
14
|
+
t.string :ip_address
|
|
15
|
+
t.string :user_agent
|
|
16
|
+
t.datetime :created_at, null: false
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
add_index :<%= table_name %>, :workflowable_type
|
|
20
|
+
add_index :<%= table_name %>, :workflowable_id
|
|
21
|
+
add_index :<%= table_name %>, :created_at
|
|
22
|
+
add_index :<%= table_name %>, %i[workflowable_type workflowable_id created_at],
|
|
23
|
+
name: "idx_signoff_events_on_workflowable_and_created_at"
|
|
24
|
+
add_index :<%= table_name %>, %i[workflowable_type workflowable_id action created_at],
|
|
25
|
+
name: "idx_signoff_events_on_workflowable_and_action"
|
|
26
|
+
add_index :<%= table_name %>, :user_id
|
|
27
|
+
<%- unless options[:skip_metadata_index] -%>
|
|
28
|
+
add_index :<%= table_name %>, :metadata, using: :gin,
|
|
29
|
+
name: "idx_signoff_events_on_metadata"
|
|
30
|
+
<%- end -%>
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Adds the signoff state column (default: approval_state) to a
|
|
3
|
+
model's table, with a sensible default and an index for fast state scopes.
|
|
4
|
+
|
|
5
|
+
Example:
|
|
6
|
+
rails generate signoff:model ExpenseReport
|
|
7
|
+
|
|
8
|
+
rails generate signoff:model Invoice --column=workflow_state --initial=pending
|
|
9
|
+
|
|
10
|
+
This will create:
|
|
11
|
+
db/migrate/XXXXXXXXXXXXXX_add_approval_state_to_expense_reports.rb
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/named_base"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module Signoff
|
|
7
|
+
module Generators
|
|
8
|
+
# Adds the workflow state column to a model's table.
|
|
9
|
+
#
|
|
10
|
+
# rails generate signoff:model ExpenseReport
|
|
11
|
+
# rails generate signoff:model Invoice --column=workflow_state --initial=pending
|
|
12
|
+
class ModelGenerator < Rails::Generators::NamedBase
|
|
13
|
+
include ActiveRecord::Generators::Migration
|
|
14
|
+
|
|
15
|
+
source_root File.expand_path("templates", __dir__)
|
|
16
|
+
|
|
17
|
+
desc "Adds the signoff state column to a model's table."
|
|
18
|
+
|
|
19
|
+
class_option :column, type: :string, default: "approval_state",
|
|
20
|
+
desc: "Name of the state column to add"
|
|
21
|
+
class_option :initial, type: :string, default: "draft",
|
|
22
|
+
desc: "Default value for the state column"
|
|
23
|
+
|
|
24
|
+
def create_migration_file
|
|
25
|
+
migration_template(
|
|
26
|
+
"migration.rb.tt",
|
|
27
|
+
File.join(db_migrate_path, "add_#{column_name}_to_#{table_name}.rb")
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def column_name
|
|
34
|
+
options[:column]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initial_state
|
|
38
|
+
options[:initial]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def migration_class_name
|
|
42
|
+
"Add#{column_name.camelize}To#{table_name.camelize}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def migration_version
|
|
46
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
def change
|
|
5
|
+
add_column :<%= table_name %>, :<%= column_name %>, :string,
|
|
6
|
+
null: false, default: <%= initial_state.inspect %>
|
|
7
|
+
add_index :<%= table_name %>, :<%= column_name %>
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Signoff
|
|
4
|
+
# Global, mutable configuration for the gem. Access via
|
|
5
|
+
# +Signoff.configuration+ or set it inside an initializer:
|
|
6
|
+
#
|
|
7
|
+
# Signoff.configure do |config|
|
|
8
|
+
# config.user_class = "User"
|
|
9
|
+
# config.track_ip_addresses = true
|
|
10
|
+
# config.store_user_agent = true
|
|
11
|
+
# end
|
|
12
|
+
class Configuration
|
|
13
|
+
# String name of the model that represents the acting user. Resolved
|
|
14
|
+
# lazily so the constant does not have to be loaded at boot.
|
|
15
|
+
attr_accessor :user_class
|
|
16
|
+
|
|
17
|
+
# When true, the +ip_address+ captured in Signoff::Current (or
|
|
18
|
+
# passed explicitly) is persisted on every event.
|
|
19
|
+
attr_accessor :track_ip_addresses
|
|
20
|
+
|
|
21
|
+
# When true, the +user_agent+ captured in Signoff::Current (or
|
|
22
|
+
# passed explicitly) is persisted on every event.
|
|
23
|
+
attr_accessor :store_user_agent
|
|
24
|
+
|
|
25
|
+
# Default column used to store the current state on workflowable models.
|
|
26
|
+
# Overridable per-model via +signoff(column: :foo)+.
|
|
27
|
+
attr_accessor :default_state_column
|
|
28
|
+
|
|
29
|
+
# Physical table name backing Signoff::Event.
|
|
30
|
+
attr_accessor :event_table_name
|
|
31
|
+
|
|
32
|
+
# When true the workflowable record is fully validated before its state
|
|
33
|
+
# column is written. When false (the default) only the state column is
|
|
34
|
+
# updated, so unrelated validation errors never block a transition.
|
|
35
|
+
attr_accessor :validate_on_transition
|
|
36
|
+
|
|
37
|
+
# +:dependent+ strategy for the events association. Because audit rows are
|
|
38
|
+
# immutable by default, use an SQL-level strategy (+:delete_all+ or
|
|
39
|
+
# +:nullify+) rather than +:destroy+, which instantiates and would be
|
|
40
|
+
# blocked by the read-only guard.
|
|
41
|
+
attr_accessor :dependent
|
|
42
|
+
|
|
43
|
+
# When true (the default) persisted events are read-only: they cannot be
|
|
44
|
+
# updated or destroyed through ActiveRecord, guaranteeing an immutable
|
|
45
|
+
# audit trail. Set to false to allow +:destroy+ as a dependent strategy.
|
|
46
|
+
attr_accessor :immutable_events
|
|
47
|
+
|
|
48
|
+
def initialize
|
|
49
|
+
@user_class = "User"
|
|
50
|
+
@track_ip_addresses = false
|
|
51
|
+
@store_user_agent = false
|
|
52
|
+
@default_state_column = :approval_state
|
|
53
|
+
@event_table_name = "signoff_events"
|
|
54
|
+
@validate_on_transition = false
|
|
55
|
+
@dependent = :delete_all
|
|
56
|
+
@immutable_events = true
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Resolve the configured user class constant. Returns nil when the constant
|
|
60
|
+
# cannot be loaded (e.g. in a library context with no User model).
|
|
61
|
+
def user_model
|
|
62
|
+
return nil if user_class.nil?
|
|
63
|
+
|
|
64
|
+
user_class.to_s.constantize
|
|
65
|
+
rescue NameError
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Signoff
|
|
4
|
+
# Optional controller mix-in that populates Signoff::Current from the
|
|
5
|
+
# request so transitions are attributed automatically:
|
|
6
|
+
#
|
|
7
|
+
# class ApplicationController < ActionController::Base
|
|
8
|
+
# include Signoff::Controller
|
|
9
|
+
# end
|
|
10
|
+
#
|
|
11
|
+
# It relies on a +current_user+ helper if one is available and respects the
|
|
12
|
+
# +track_ip_addresses+ / +store_user_agent+ configuration flags.
|
|
13
|
+
module Controller
|
|
14
|
+
extend ActiveSupport::Concern
|
|
15
|
+
|
|
16
|
+
included do
|
|
17
|
+
before_action :set_signoff_context
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def set_signoff_context
|
|
23
|
+
configuration = Signoff.configuration
|
|
24
|
+
|
|
25
|
+
Signoff::Current.user = current_user if respond_to?(:current_user, true)
|
|
26
|
+
|
|
27
|
+
return unless respond_to?(:request) && request
|
|
28
|
+
|
|
29
|
+
Signoff::Current.ip_address = request.remote_ip if configuration.track_ip_addresses
|
|
30
|
+
return unless configuration.store_user_agent
|
|
31
|
+
|
|
32
|
+
Signoff::Current.user_agent = request.user_agent
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/current_attributes"
|
|
4
|
+
|
|
5
|
+
module Signoff
|
|
6
|
+
# Request-scoped context used to attribute transitions when the acting user,
|
|
7
|
+
# IP address or user agent are not passed explicitly to a transition method.
|
|
8
|
+
#
|
|
9
|
+
# Populate it from a controller (see Signoff::Controller) or
|
|
10
|
+
# manually:
|
|
11
|
+
#
|
|
12
|
+
# Signoff::Current.set(user: current_user, ip_address: request.remote_ip) do
|
|
13
|
+
# report.approve!
|
|
14
|
+
# end
|
|
15
|
+
class Current < ActiveSupport::CurrentAttributes
|
|
16
|
+
attribute :user, :ip_address, :user_agent
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Signoff
|
|
4
|
+
# Immutable-once-validated description of a model's workflow: its states, the
|
|
5
|
+
# allowed forward transitions, the reject state, authorization guards and
|
|
6
|
+
# lifecycle callbacks. Built by Signoff::DSL and stored on the model
|
|
7
|
+
# class as +signoff_definition+.
|
|
8
|
+
class Definition
|
|
9
|
+
attr_reader :states, :transitions, :authorizations,
|
|
10
|
+
:before_callbacks, :after_callbacks, :state_column
|
|
11
|
+
attr_writer :initial_state
|
|
12
|
+
|
|
13
|
+
def initialize(state_column: :approval_state)
|
|
14
|
+
@states = []
|
|
15
|
+
@transitions = {} # from(Symbol) => [to(Symbol), ...]
|
|
16
|
+
@authorizations = {} # from(Symbol) => callable guard
|
|
17
|
+
@before_callbacks = []
|
|
18
|
+
@after_callbacks = []
|
|
19
|
+
@initial_state = nil
|
|
20
|
+
@reject_state = nil
|
|
21
|
+
@state_column = state_column.to_sym
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# --- builders (called by the DSL) ------------------------------------
|
|
25
|
+
|
|
26
|
+
def add_state(name)
|
|
27
|
+
name = name.to_sym
|
|
28
|
+
raise DefinitionError, "duplicate state #{name.inspect}" if @states.include?(name)
|
|
29
|
+
|
|
30
|
+
@states << name
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def add_transition(from, to)
|
|
34
|
+
from = from.to_sym
|
|
35
|
+
Array(to).each do |target|
|
|
36
|
+
target = target.to_sym
|
|
37
|
+
@transitions[from] ||= []
|
|
38
|
+
@transitions[from] << target unless @transitions[from].include?(target)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def add_authorization(from, guard)
|
|
43
|
+
@authorizations[from.to_sym] = guard
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def reject_state=(name)
|
|
47
|
+
@reject_state = name&.to_sym
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
attr_reader :reject_state
|
|
51
|
+
|
|
52
|
+
# --- queries ---------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
def initial_state
|
|
55
|
+
@initial_state || @states.first
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Targets reachable from +state+ via a declared forward transition.
|
|
59
|
+
def forward_targets(state)
|
|
60
|
+
@transitions[state.to_sym] || []
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# States with no outgoing forward transition.
|
|
64
|
+
def terminal_states
|
|
65
|
+
@states.reject { |s| forward_targets(s).any? }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Terminal states that represent successful completion (everything that is
|
|
69
|
+
# terminal except the reject state).
|
|
70
|
+
def approval_terminal_states
|
|
71
|
+
terminal_states - [reject_state].compact
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def guard_for(state)
|
|
75
|
+
@authorizations[state.to_sym]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Resolve the next state when advancing forward from +from+. +to+ may be
|
|
79
|
+
# supplied to disambiguate when several forward transitions exist.
|
|
80
|
+
def next_state(from, to: nil)
|
|
81
|
+
from = from.to_sym
|
|
82
|
+
targets = forward_targets(from)
|
|
83
|
+
|
|
84
|
+
if to
|
|
85
|
+
to = to.to_sym
|
|
86
|
+
return to if targets.include?(to)
|
|
87
|
+
|
|
88
|
+
raise InvalidTransitionError,
|
|
89
|
+
"no transition declared from #{from.inspect} to #{to.inspect}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
case targets.size
|
|
93
|
+
when 0
|
|
94
|
+
raise InvalidTransitionError,
|
|
95
|
+
"no forward transition declared from #{from.inspect}"
|
|
96
|
+
when 1
|
|
97
|
+
targets.first
|
|
98
|
+
else
|
|
99
|
+
raise InvalidTransitionError,
|
|
100
|
+
"ambiguous transition from #{from.inspect}; pass to: " \
|
|
101
|
+
"(one of #{targets.inspect})"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# --- validation ------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
def validate!
|
|
108
|
+
raise DefinitionError, "no states have been defined" if @states.empty?
|
|
109
|
+
|
|
110
|
+
validate_initial_state!
|
|
111
|
+
validate_transitions!
|
|
112
|
+
validate_reject_state!
|
|
113
|
+
validate_authorizations!
|
|
114
|
+
self
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def validate_initial_state!
|
|
120
|
+
raise DefinitionError, "an initial state is required" if initial_state.nil?
|
|
121
|
+
return if @states.include?(initial_state)
|
|
122
|
+
|
|
123
|
+
raise DefinitionError,
|
|
124
|
+
"initial state #{initial_state.inspect} is not a declared state"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def validate_transitions!
|
|
128
|
+
@transitions.each do |from, targets|
|
|
129
|
+
unless @states.include?(from)
|
|
130
|
+
raise DefinitionError,
|
|
131
|
+
"transition from undeclared state #{from.inspect}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
targets.each do |target|
|
|
135
|
+
next if @states.include?(target)
|
|
136
|
+
|
|
137
|
+
raise DefinitionError,
|
|
138
|
+
"transition to undeclared state #{target.inspect}"
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def validate_reject_state!
|
|
144
|
+
return if reject_state.nil? || @states.include?(reject_state)
|
|
145
|
+
|
|
146
|
+
raise DefinitionError,
|
|
147
|
+
"reject state #{reject_state.inspect} is not a declared state"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def validate_authorizations!
|
|
151
|
+
@authorizations.each_key do |state|
|
|
152
|
+
next if @states.include?(state)
|
|
153
|
+
|
|
154
|
+
raise DefinitionError,
|
|
155
|
+
"allow_transition references undeclared state #{state.inspect}"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
data/lib/signoff/dsl.rb
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Signoff
|
|
4
|
+
# The receiver for the +signoff do ... end+ block. Every method
|
|
5
|
+
# here mutates the Signoff::Definition it was built with.
|
|
6
|
+
#
|
|
7
|
+
# signoff do
|
|
8
|
+
# state :draft
|
|
9
|
+
# state :manager_review
|
|
10
|
+
# state :approved
|
|
11
|
+
# state :rejected
|
|
12
|
+
#
|
|
13
|
+
# initial_state :draft
|
|
14
|
+
#
|
|
15
|
+
# transition :draft, to: :manager_review
|
|
16
|
+
# transition :manager_review, to: :approved
|
|
17
|
+
#
|
|
18
|
+
# reject_to :rejected
|
|
19
|
+
#
|
|
20
|
+
# allow_transition :manager_review do |user|
|
|
21
|
+
# user.manager?
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# after_transition do |record, event|
|
|
25
|
+
# WorkflowNotificationJob.perform_later(record.id, event.id)
|
|
26
|
+
# end
|
|
27
|
+
# end
|
|
28
|
+
class DSL
|
|
29
|
+
def initialize(definition)
|
|
30
|
+
@definition = definition
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Declare a state. Pass +initial: true+ to mark it as the starting state
|
|
34
|
+
# (equivalent to a separate +initial_state+ call).
|
|
35
|
+
def state(name, initial: false)
|
|
36
|
+
@definition.add_state(name)
|
|
37
|
+
@definition.initial_state = name if initial
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Declare several states at once: +states :draft, :review, :approved+.
|
|
41
|
+
def states(*names)
|
|
42
|
+
names.flatten.each { |name| state(name) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Explicitly set the starting state. Defaults to the first declared state.
|
|
46
|
+
def initial_state(name)
|
|
47
|
+
@definition.initial_state = name
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Declare a forward transition. +to+ accepts a single state or an array.
|
|
51
|
+
def transition(from, to:)
|
|
52
|
+
@definition.add_transition(from, to)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The state a record moves to when +reject!+ is called.
|
|
56
|
+
def reject_to(state)
|
|
57
|
+
@definition.reject_state = state
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Authorize transitions out of +from_state+. The block receives the acting
|
|
61
|
+
# user (and optionally the record) and must return a truthy value to allow
|
|
62
|
+
# the transition.
|
|
63
|
+
#
|
|
64
|
+
# allow_transition :finance_review do |user, record|
|
|
65
|
+
# user.finance_team? && record.amount <= user.approval_limit
|
|
66
|
+
# end
|
|
67
|
+
def allow_transition(from_state, &block)
|
|
68
|
+
raise DefinitionError, "allow_transition requires a block" unless block
|
|
69
|
+
|
|
70
|
+
@definition.add_authorization(from_state, block)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Run +block+ inside the transition's transaction, before the state column
|
|
74
|
+
# is written. Receives +(record, from_state, to_state)+.
|
|
75
|
+
def before_transition(&block)
|
|
76
|
+
raise DefinitionError, "before_transition requires a block" unless block
|
|
77
|
+
|
|
78
|
+
@definition.before_callbacks << block
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Run +block+ after the transition's transaction commits. Receives
|
|
82
|
+
# +(record, event)+. The created event is already persisted, so this is the
|
|
83
|
+
# right place to enqueue jobs or send mail.
|
|
84
|
+
def after_transition(&block)
|
|
85
|
+
raise DefinitionError, "after_transition requires a block" unless block
|
|
86
|
+
|
|
87
|
+
@definition.after_callbacks << block
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/engine"
|
|
4
|
+
|
|
5
|
+
module Signoff
|
|
6
|
+
# Minimal Rails engine. Its sole job is to expose +app/models+ (so
|
|
7
|
+
# +Signoff::Event+ autoloads in the host application) and the
|
|
8
|
+
# generators under +lib/generators+.
|
|
9
|
+
class Engine < ::Rails::Engine
|
|
10
|
+
# Register the Engine itself (a Rails::Engine responds to +eager_load!+);
|
|
11
|
+
# pushing the bare Signoff module here would crash host boot under
|
|
12
|
+
# eager loading (the production default) with NoMethodError: eager_load!.
|
|
13
|
+
config.eager_load_namespaces << Signoff::Engine
|
|
14
|
+
|
|
15
|
+
config.app_generators do |generator|
|
|
16
|
+
generator.orm :active_record
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Signoff
|
|
4
|
+
# Base class for every error raised by the gem. Rescue this to catch any
|
|
5
|
+
# approval-workflow specific failure.
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Raised while building a workflow definition with the DSL when the
|
|
9
|
+
# definition is invalid (duplicate states, unknown transition targets,
|
|
10
|
+
# missing initial state, ...).
|
|
11
|
+
class DefinitionError < Error; end
|
|
12
|
+
|
|
13
|
+
# Raised at runtime when a transition is not permitted by the workflow
|
|
14
|
+
# definition (no edge from the current state, ambiguous target, rejecting a
|
|
15
|
+
# finalized record, ...).
|
|
16
|
+
class InvalidTransitionError < Error; end
|
|
17
|
+
|
|
18
|
+
# Raised when an authorization guard declared via +allow_transition+ rejects
|
|
19
|
+
# the acting user (or when a user is required but none was supplied).
|
|
20
|
+
class UnauthorizedError < Error; end
|
|
21
|
+
|
|
22
|
+
# Raised when a model has +include Signoff+ but never declared an
|
|
23
|
+
# +signoff do ... end+ block.
|
|
24
|
+
class NotConfiguredError < Error; end
|
|
25
|
+
|
|
26
|
+
# Raised when the workflowable table is missing the state column the workflow
|
|
27
|
+
# expects (run +rails g signoff:model NAME+ or add it yourself).
|
|
28
|
+
class MissingColumnError < Error; end
|
|
29
|
+
end
|