signoff 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +31 -1
- data/README.md +67 -5
- data/lib/signoff/definition.rb +88 -9
- data/lib/signoff/dsl.rb +19 -1
- data/lib/signoff/model.rb +56 -5
- data/lib/signoff/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 733febbc93f96129a33e8a61d7c9a76e70ae48570ac2db24e244c4f2f494bbf5
|
|
4
|
+
data.tar.gz: 2fda8591c1e65d3fdbccea29174bc3e0a355bdadadf51287784e94ff80588ca1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f23635f242f51897f94723519bfb4ed1edb96d652ff5fb2629cab3d20c9b1360f7366b85a643b580379c1abed316725f3ad8470639f885fd8ade0035f8688a1b
|
|
7
|
+
data.tar.gz: 149239b36bbcbe875e4732181a86605af121939ac1404f09adeddc4920f45c76926afb4fd66c34e015301049c892041c272d4a99e5850d9cda71735b5172bf88
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2026-06-23
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Custom action verbs.** Declare a named action inside the `signoff` block —
|
|
15
|
+
`action(name, to:, from: nil)` — to model any workflow verb beyond the built-in
|
|
16
|
+
`submit`/`approve`/`reject`. Each declared action generates a `name!` transition
|
|
17
|
+
method (accepting the usual `user:`, `comment:`, `metadata:`, `ip_address:`,
|
|
18
|
+
`user_agent:` keywords) and a non-raising `can_name?(user = nil)` predicate, and
|
|
19
|
+
records a `Signoff::Event` whose `action` is the verb name verbatim — so the
|
|
20
|
+
audit trail stays semantically truthful (e.g. `request_changes`, `cancel`,
|
|
21
|
+
`escalate`). Like `reject`, a custom action is a guarded *side transition*: its
|
|
22
|
+
target is never a forward edge, so it cannot make `approve!` ambiguous. `from:`
|
|
23
|
+
limits the permitted source states (single state or array) and defaults to every
|
|
24
|
+
non-finalized state except the action's own target. Custom actions reuse the
|
|
25
|
+
source state's `allow_transition` guard.
|
|
26
|
+
- **Multi-source forward transitions.** `transition` now accepts an array for
|
|
27
|
+
`from` as well as `to`, so several states can share one forward edge —
|
|
28
|
+
`transition [:draft, :rejected, :changes_requested], to: :review`.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- `approved?` / the `.approved` scope now exclude custom-action target states, and
|
|
33
|
+
`pending?` / the `.pending` scope treat a *terminal* custom-action target (e.g.
|
|
34
|
+
`cancelled`) as finalized while a *looping* target (e.g. `changes_requested`,
|
|
35
|
+
which re-submits) stays in flight. Workflows without custom actions are
|
|
36
|
+
unaffected — the finalized set remains exactly the approval terminals plus the
|
|
37
|
+
reject state.
|
|
38
|
+
|
|
10
39
|
## [0.1.0] - 2026-06-05
|
|
11
40
|
|
|
12
41
|
Initial public release. `signoff` adds concurrency-safe approval workflows
|
|
@@ -75,5 +104,6 @@ and 8.x, backed by PostgreSQL — no external services required.
|
|
|
75
104
|
`MissingColumnError`.
|
|
76
105
|
- **Rails 7.x and 8.x support** on Ruby >= 3.2 with PostgreSQL.
|
|
77
106
|
|
|
78
|
-
[Unreleased]: https://github.com/JijoBose/Signoff/compare/v0.
|
|
107
|
+
[Unreleased]: https://github.com/JijoBose/Signoff/compare/v0.2.0...HEAD
|
|
108
|
+
[0.2.0]: https://github.com/JijoBose/Signoff/compare/v0.1.0...v0.2.0
|
|
79
109
|
[0.1.0]: https://github.com/JijoBose/Signoff/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -471,15 +471,71 @@ the state column for a single model (e.g. `signoff(column: :workflow_state) do`)
|
|
|
471
471
|
| `state(name, initial: false)` | Declare a state. `initial: true` marks the start state. |
|
|
472
472
|
| `states(*names)` | Declare several states at once. |
|
|
473
473
|
| `initial_state(name)` | Set the start state explicitly (defaults to the first declared). |
|
|
474
|
-
| `transition(from, to:)` | Declare a forward transition. `to:`
|
|
474
|
+
| `transition(from, to:)` | Declare a forward transition. Both `from` and `to:` accept a symbol or an array. |
|
|
475
475
|
| `reject_to(state)` | The state `reject!` moves a record into. |
|
|
476
|
+
| `action(name, to:, from: nil)` | Declare a custom verb (e.g. `:request_changes`). Generates `name!` / `can_name?` and records `action` verbatim. A guarded side transition — never a forward edge. `from:` limits sources (defaults to any non-finalized state). |
|
|
476
477
|
| `allow_transition(from) { \|user[, record]\| ... }` | Authorize transitions **out of** `from`. |
|
|
477
478
|
| `before_transition { \|record, from, to\| ... }` | Run inside the transaction, before the state is written. |
|
|
478
479
|
| `after_transition { \|record, event\| ... }` | Run after the transaction commits (great for jobs/mail). |
|
|
479
480
|
|
|
480
481
|
Definitions are validated when the class loads. You'll get a descriptive
|
|
481
482
|
`Signoff::DefinitionError` for duplicate states, transitions to/from
|
|
482
|
-
undeclared states, a missing initial state,
|
|
483
|
+
undeclared states, a missing initial state, an undeclared reject state, or a
|
|
484
|
+
custom `action` that is named after a built-in verb or targets an undeclared
|
|
485
|
+
state.
|
|
486
|
+
|
|
487
|
+
### Custom actions
|
|
488
|
+
|
|
489
|
+
`submit`, `approve`, and `reject` cover the common path, but real workflows have
|
|
490
|
+
more verbs — request changes, escalate, cancel, return-to-sender. Declare them
|
|
491
|
+
with `action` and the audit trail stays truthful (the recorded `action` is the
|
|
492
|
+
verb name, not a repurposed `"reject"`):
|
|
493
|
+
|
|
494
|
+
```ruby
|
|
495
|
+
class Post < ApplicationRecord
|
|
496
|
+
include Signoff
|
|
497
|
+
|
|
498
|
+
signoff do
|
|
499
|
+
states :draft, :in_review, :approved, :rejected, :changes_requested, :cancelled
|
|
500
|
+
|
|
501
|
+
# Several states share one forward edge (re-submission re-enters review):
|
|
502
|
+
transition %i[draft rejected changes_requested], to: :in_review
|
|
503
|
+
transition :in_review, to: :approved
|
|
504
|
+
|
|
505
|
+
reject_to :rejected
|
|
506
|
+
|
|
507
|
+
# A looping off-ramp — re-submission brings it back into review:
|
|
508
|
+
action :request_changes, from: :in_review, to: :changes_requested
|
|
509
|
+
# A terminal off-ramp from any in-flight state (no :from given):
|
|
510
|
+
action :cancel, to: :cancelled
|
|
511
|
+
|
|
512
|
+
allow_transition :in_review do |user|
|
|
513
|
+
user.editor?
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
post.request_changes!(user: current_user, comment: "Tighten the intro")
|
|
519
|
+
post.current_state # => :changes_requested
|
|
520
|
+
post.workflow_history.last.action # => "request_changes" (truthful)
|
|
521
|
+
post.can_request_changes?(other) # => true / false (never raises)
|
|
522
|
+
post.submit! # changes_requested -> in_review again
|
|
523
|
+
post.cancel!(user: current_user) # -> cancelled
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
A custom action behaves like a first-class `reject`:
|
|
527
|
+
|
|
528
|
+
- It is a **guarded side transition** — its target is never a forward edge, so a
|
|
529
|
+
state can offer `approve!` *and* `request_changes!`/`cancel!` without making
|
|
530
|
+
`approve!` ambiguous.
|
|
531
|
+
- It reuses the **source state's `allow_transition` guard**. Unauthorized calls
|
|
532
|
+
raise `Signoff::UnauthorizedError`; the generated `can_<name>?` predicate reports
|
|
533
|
+
permission without raising.
|
|
534
|
+
- `from:` limits the permitted source states (a symbol or array). Omit it and the
|
|
535
|
+
action is allowed from any non-finalized state except its own target.
|
|
536
|
+
- Targets that **loop back** into the flow (like `changes_requested`) count as
|
|
537
|
+
*in flight* (`pending?` / `.pending`); targets with no way out (like
|
|
538
|
+
`cancelled`) count as *finalized* — and neither is ever reported as `approved?`.
|
|
483
539
|
|
|
484
540
|
## Instance API
|
|
485
541
|
|
|
@@ -491,6 +547,9 @@ Including `Signoff` and declaring a workflow generates:
|
|
|
491
547
|
report.submit!(user: nil, comment: nil, metadata: {}, to: nil)
|
|
492
548
|
report.approve!(user: nil, comment: nil, metadata: {}, to: nil)
|
|
493
549
|
report.reject!(user: nil, comment: nil, metadata: {})
|
|
550
|
+
|
|
551
|
+
# Plus one method per declared custom action, e.g.:
|
|
552
|
+
report.request_changes!(user: nil, comment: nil, metadata: {})
|
|
494
553
|
```
|
|
495
554
|
|
|
496
555
|
- `submit!` and `approve!` both advance the **single forward transition** from the
|
|
@@ -499,6 +558,8 @@ report.reject!(user: nil, comment: nil, metadata: {})
|
|
|
499
558
|
- When a state has **more than one** forward transition, pass `to:` to disambiguate
|
|
500
559
|
(`report.approve!(to: :finance_review)`).
|
|
501
560
|
- `reject!` moves the record to the `reject_to` state.
|
|
561
|
+
- Each custom `action :name` generates a `name!` method that records `"name"` as
|
|
562
|
+
the audit `action` (see [Custom actions](#custom-actions)).
|
|
502
563
|
- All accept `ip_address:` and `user_agent:` keyword overrides (see
|
|
503
564
|
[Configuration](#configuration)).
|
|
504
565
|
- Each returns the created `Signoff::Event`.
|
|
@@ -521,11 +582,12 @@ report.finance_review?
|
|
|
521
582
|
### Authorization predicates
|
|
522
583
|
|
|
523
584
|
```ruby
|
|
524
|
-
report.can_approve?(current_user)
|
|
525
|
-
report.can_reject?(current_user)
|
|
585
|
+
report.can_approve?(current_user) # => true / false (never raises)
|
|
586
|
+
report.can_reject?(current_user) # => true / false
|
|
587
|
+
report.can_request_changes?(current_user) # => one per custom action
|
|
526
588
|
```
|
|
527
589
|
|
|
528
|
-
|
|
590
|
+
All fall back to `Signoff::Current.user` when no argument is given.
|
|
529
591
|
|
|
530
592
|
### Audit helpers
|
|
531
593
|
|
data/lib/signoff/definition.rb
CHANGED
|
@@ -6,14 +6,19 @@ module Signoff
|
|
|
6
6
|
# lifecycle callbacks. Built by Signoff::DSL and stored on the model
|
|
7
7
|
# class as +signoff_definition+.
|
|
8
8
|
class Definition
|
|
9
|
+
# Built-in verbs that own their own transition methods on the model; a
|
|
10
|
+
# custom +action+ may not reuse these names.
|
|
11
|
+
RESERVED_ACTIONS = %i[submit approve reject].freeze
|
|
12
|
+
|
|
9
13
|
attr_reader :states, :transitions, :authorizations,
|
|
10
|
-
:before_callbacks, :after_callbacks, :state_column
|
|
14
|
+
:before_callbacks, :after_callbacks, :state_column, :actions
|
|
11
15
|
attr_writer :initial_state
|
|
12
16
|
|
|
13
17
|
def initialize(state_column: :approval_state)
|
|
14
18
|
@states = []
|
|
15
19
|
@transitions = {} # from(Symbol) => [to(Symbol), ...]
|
|
16
20
|
@authorizations = {} # from(Symbol) => callable guard
|
|
21
|
+
@actions = {} # name(Symbol) => { to: Symbol, from: [Symbol]|nil }
|
|
17
22
|
@before_callbacks = []
|
|
18
23
|
@after_callbacks = []
|
|
19
24
|
@initial_state = nil
|
|
@@ -31,11 +36,13 @@ module Signoff
|
|
|
31
36
|
end
|
|
32
37
|
|
|
33
38
|
def add_transition(from, to)
|
|
34
|
-
from
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
+
Array(from).each do |source|
|
|
40
|
+
source = source.to_sym
|
|
41
|
+
Array(to).each do |target|
|
|
42
|
+
target = target.to_sym
|
|
43
|
+
@transitions[source] ||= []
|
|
44
|
+
@transitions[source] << target unless @transitions[source].include?(target)
|
|
45
|
+
end
|
|
39
46
|
end
|
|
40
47
|
end
|
|
41
48
|
|
|
@@ -43,6 +50,22 @@ module Signoff
|
|
|
43
50
|
@authorizations[from.to_sym] = guard
|
|
44
51
|
end
|
|
45
52
|
|
|
53
|
+
# Register a named custom action: a guarded "side transition" that moves a
|
|
54
|
+
# record to +to+ and records an event whose +action+ is the name verbatim.
|
|
55
|
+
# Like +reject+, the target is NOT a forward transition, so it never makes
|
|
56
|
+
# +approve!+ ambiguous. +from+ limits the source states (defaults to every
|
|
57
|
+
# non-finalized state except the target itself).
|
|
58
|
+
def add_action(name, to:, from: nil)
|
|
59
|
+
name = name.to_sym
|
|
60
|
+
raise DefinitionError, "duplicate action #{name.inspect}" if @actions.key?(name)
|
|
61
|
+
raise DefinitionError, "action #{name.inspect} requires a :to state" if to.nil?
|
|
62
|
+
|
|
63
|
+
@actions[name] = {
|
|
64
|
+
to: to.to_sym,
|
|
65
|
+
from: from.nil? ? nil : Array(from).map(&:to_sym)
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
46
69
|
def reject_state=(name)
|
|
47
70
|
@reject_state = name&.to_sym
|
|
48
71
|
end
|
|
@@ -65,10 +88,42 @@ module Signoff
|
|
|
65
88
|
@states.reject { |s| forward_targets(s).any? }
|
|
66
89
|
end
|
|
67
90
|
|
|
68
|
-
# Terminal states that represent successful completion
|
|
69
|
-
#
|
|
91
|
+
# Terminal states that represent successful completion: terminal via the
|
|
92
|
+
# forward flow and not reachable only as an off-ramp (reject or a custom
|
|
93
|
+
# action target). This keeps +approved?+ true for genuine end states only.
|
|
70
94
|
def approval_terminal_states
|
|
71
|
-
terminal_states - [reject_state].compact
|
|
95
|
+
terminal_states - [reject_state].compact - action_target_states
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Every state that ends the workflow: successful terminals, the reject
|
|
99
|
+
# state, and any custom-action target that has no forward transition out
|
|
100
|
+
# (e.g. a +cancel+ action's +cancelled+ state). Off-ramp targets that loop
|
|
101
|
+
# back (e.g. +changes_requested+ -> re-submit) stay in flight, not finalized.
|
|
102
|
+
def finalized_states
|
|
103
|
+
terminal_action_targets = action_target_states.select { |s| forward_targets(s).empty? }
|
|
104
|
+
(approval_terminal_states + [reject_state].compact + terminal_action_targets).uniq
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The distinct set of states reachable via custom actions.
|
|
108
|
+
def action_target_states
|
|
109
|
+
@actions.values.map { |spec| spec[:to] }.uniq
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def action_names
|
|
113
|
+
@actions.keys
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def action_for(name)
|
|
117
|
+
@actions[name.to_sym]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# States a custom action may be invoked from. An explicit +from:+ wins;
|
|
121
|
+
# otherwise any non-finalized state except the action's own target.
|
|
122
|
+
def action_allowed_from(name)
|
|
123
|
+
spec = @actions.fetch(name.to_sym)
|
|
124
|
+
return spec[:from] if spec[:from]
|
|
125
|
+
|
|
126
|
+
states - finalized_states - [spec[:to]]
|
|
72
127
|
end
|
|
73
128
|
|
|
74
129
|
def guard_for(state)
|
|
@@ -111,6 +166,7 @@ module Signoff
|
|
|
111
166
|
validate_transitions!
|
|
112
167
|
validate_reject_state!
|
|
113
168
|
validate_authorizations!
|
|
169
|
+
validate_actions!
|
|
114
170
|
self
|
|
115
171
|
end
|
|
116
172
|
|
|
@@ -155,5 +211,28 @@ module Signoff
|
|
|
155
211
|
"allow_transition references undeclared state #{state.inspect}"
|
|
156
212
|
end
|
|
157
213
|
end
|
|
214
|
+
|
|
215
|
+
def validate_actions!
|
|
216
|
+
@actions.each do |name, spec|
|
|
217
|
+
if RESERVED_ACTIONS.include?(name)
|
|
218
|
+
raise DefinitionError,
|
|
219
|
+
"action #{name.inspect} is reserved; #{name}! is a built-in verb"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
unless @states.include?(spec[:to])
|
|
223
|
+
raise DefinitionError,
|
|
224
|
+
"action #{name.inspect} targets undeclared state #{spec[:to].inspect}"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
next unless spec[:from]
|
|
228
|
+
|
|
229
|
+
spec[:from].each do |from|
|
|
230
|
+
next if @states.include?(from)
|
|
231
|
+
|
|
232
|
+
raise DefinitionError,
|
|
233
|
+
"action #{name.inspect} references undeclared from-state #{from.inspect}"
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
158
237
|
end
|
|
159
238
|
end
|
data/lib/signoff/dsl.rb
CHANGED
|
@@ -47,7 +47,9 @@ module Signoff
|
|
|
47
47
|
@definition.initial_state = name
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
-
# Declare a forward transition. +to+
|
|
50
|
+
# Declare a forward transition. Both +from+ and +to+ accept a single state
|
|
51
|
+
# or an array, so several source states can share one forward edge
|
|
52
|
+
# (e.g. +transition [:draft, :rejected], to: :review+).
|
|
51
53
|
def transition(from, to:)
|
|
52
54
|
@definition.add_transition(from, to)
|
|
53
55
|
end
|
|
@@ -57,6 +59,22 @@ module Signoff
|
|
|
57
59
|
@definition.reject_state = state
|
|
58
60
|
end
|
|
59
61
|
|
|
62
|
+
# Declare a named custom action. It generates a +name!+ transition method
|
|
63
|
+
# and a +can_name?+ predicate on the model, records an event whose +action+
|
|
64
|
+
# is the name verbatim, and is authorized by the same +allow_transition+
|
|
65
|
+
# guard as the source state. Like +reject+, the target is a side-transition
|
|
66
|
+
# (never a forward edge), so +approve!+ stays unambiguous. This is the
|
|
67
|
+
# extension point for modelling any workflow verb beyond submit/approve/reject.
|
|
68
|
+
#
|
|
69
|
+
# action :request_changes, from: :pending_review, to: :changes_requested
|
|
70
|
+
# action :cancel, to: :cancelled # from any in-flight state
|
|
71
|
+
#
|
|
72
|
+
# +from+ is optional and may be a single state or an array; when omitted the
|
|
73
|
+
# action is allowed from every non-finalized state except its own target.
|
|
74
|
+
def action(name, to:, from: nil)
|
|
75
|
+
@definition.add_action(name, to: to, from: from)
|
|
76
|
+
end
|
|
77
|
+
|
|
60
78
|
# Authorize transitions out of +from_state+. The block receives the acting
|
|
61
79
|
# user (and optionally the record) and must return a truthy value to allow
|
|
62
80
|
# the transition.
|
data/lib/signoff/model.rb
CHANGED
|
@@ -47,6 +47,7 @@ module Signoff
|
|
|
47
47
|
dependent: Signoff.configuration.dependent
|
|
48
48
|
|
|
49
49
|
_define_signoff_predicates(definition)
|
|
50
|
+
_define_signoff_actions(definition)
|
|
50
51
|
definition
|
|
51
52
|
end
|
|
52
53
|
|
|
@@ -84,11 +85,11 @@ module Signoff
|
|
|
84
85
|
reject_state ? in_state(reject_state) : none
|
|
85
86
|
end
|
|
86
87
|
|
|
87
|
-
# Records that are neither approved nor rejected (still in flight).
|
|
88
|
+
# Records that are neither approved nor rejected (still in flight). A
|
|
89
|
+
# custom-action off-ramp that loops back (e.g. +changes_requested+) counts
|
|
90
|
+
# as in flight; one that terminates (e.g. +cancelled+) counts as finalized.
|
|
88
91
|
def pending
|
|
89
|
-
|
|
90
|
-
finalized = (definition.approval_terminal_states +
|
|
91
|
-
[definition.reject_state].compact).map(&:to_s)
|
|
92
|
+
finalized = signoff_definition!.finalized_states.map(&:to_s)
|
|
92
93
|
finalized.empty? ? all : where.not(signoff_column => finalized)
|
|
93
94
|
end
|
|
94
95
|
|
|
@@ -103,6 +104,26 @@ module Signoff
|
|
|
103
104
|
define_method(predicate) { current_state == state }
|
|
104
105
|
end
|
|
105
106
|
end
|
|
107
|
+
|
|
108
|
+
# For each declared custom action, generate a +name!+ transition method
|
|
109
|
+
# and a +can_name?+ predicate, mirroring the built-in approve!/can_approve?.
|
|
110
|
+
def _define_signoff_actions(definition)
|
|
111
|
+
definition.action_names.each do |name|
|
|
112
|
+
bang = "#{name}!"
|
|
113
|
+
unless method_defined?(bang) || private_method_defined?(bang)
|
|
114
|
+
define_method(bang) do |user: nil, comment: nil, metadata: {},
|
|
115
|
+
ip_address: nil, user_agent: nil|
|
|
116
|
+
_perform_action!(name, user: user, comment: comment, metadata: metadata,
|
|
117
|
+
ip_address: ip_address, user_agent: user_agent)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
predicate = "can_#{name}?"
|
|
122
|
+
next if method_defined?(predicate) || private_method_defined?(predicate)
|
|
123
|
+
|
|
124
|
+
define_method(predicate) { |user = nil| _can_perform_action?(name, user) }
|
|
125
|
+
end
|
|
126
|
+
end
|
|
106
127
|
end
|
|
107
128
|
|
|
108
129
|
# --- state -----------------------------------------------------------
|
|
@@ -125,7 +146,7 @@ module Signoff
|
|
|
125
146
|
end
|
|
126
147
|
|
|
127
148
|
def pending?
|
|
128
|
-
!
|
|
149
|
+
!self.class.signoff_definition!.finalized_states.include?(current_state)
|
|
129
150
|
end
|
|
130
151
|
|
|
131
152
|
# --- transitions -----------------------------------------------------
|
|
@@ -241,6 +262,36 @@ module Signoff
|
|
|
241
262
|
ip_address: ip_address, user_agent: user_agent)
|
|
242
263
|
end
|
|
243
264
|
|
|
265
|
+
# Drive a declared custom action: validate the current state is a permitted
|
|
266
|
+
# source, then record an event whose action is the verb name verbatim.
|
|
267
|
+
def _perform_action!(name, user:, comment:, metadata:, ip_address:, user_agent:)
|
|
268
|
+
definition = self.class.signoff_definition!
|
|
269
|
+
spec = definition.action_for(name)
|
|
270
|
+
unless spec
|
|
271
|
+
raise InvalidTransitionError, "no action #{name.inspect} declared for #{self.class.name}"
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
from = current_state
|
|
275
|
+
allowed = definition.action_allowed_from(name)
|
|
276
|
+
unless allowed.include?(from)
|
|
277
|
+
raise InvalidTransitionError,
|
|
278
|
+
"cannot #{name} from #{from.inspect} " \
|
|
279
|
+
"(allowed from: #{allowed.inspect})"
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
_perform_transition!(action: name.to_s, from: from, to: spec[:to], user: user,
|
|
283
|
+
comment: comment, metadata: metadata,
|
|
284
|
+
ip_address: ip_address, user_agent: user_agent)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def _can_perform_action?(name, user)
|
|
288
|
+
definition = self.class.signoff_definition!
|
|
289
|
+
return false unless definition.action_for(name)
|
|
290
|
+
return false unless definition.action_allowed_from(name).include?(current_state)
|
|
291
|
+
|
|
292
|
+
_guard_satisfied?(current_state, user || Signoff::Current.user)
|
|
293
|
+
end
|
|
294
|
+
|
|
244
295
|
def _perform_transition!(action:, from:, to:, user:, comment:, metadata:,
|
|
245
296
|
ip_address:, user_agent:)
|
|
246
297
|
if new_record?
|
data/lib/signoff/version.rb
CHANGED