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,424 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module Signoff
|
|
6
|
+
# The behaviour mixed into ActiveRecord models via +include Signoff+.
|
|
7
|
+
# It provides the +signoff+ class macro, the transition methods
|
|
8
|
+
# (+submit!+, +approve!+, +reject!+), state predicates, query scopes and the
|
|
9
|
+
# audit-trail helpers.
|
|
10
|
+
module Model
|
|
11
|
+
extend ActiveSupport::Concern
|
|
12
|
+
|
|
13
|
+
included do
|
|
14
|
+
class_attribute :signoff_definition,
|
|
15
|
+
instance_accessor: false,
|
|
16
|
+
default: nil
|
|
17
|
+
|
|
18
|
+
after_initialize :_set_initial_approval_state, if: :new_record?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class_methods do
|
|
22
|
+
# Declare the workflow for this model.
|
|
23
|
+
#
|
|
24
|
+
# signoff do
|
|
25
|
+
# state :draft
|
|
26
|
+
# state :approved
|
|
27
|
+
# transition :draft, to: :approved
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# +column+ overrides the state column for this model (defaults to
|
|
31
|
+
# +Signoff.configuration.default_state_column+).
|
|
32
|
+
def signoff(column: nil, &block)
|
|
33
|
+
raise DefinitionError, "signoff requires a block" unless block
|
|
34
|
+
|
|
35
|
+
definition = Signoff::Definition.new(
|
|
36
|
+
state_column: column || Signoff.configuration.default_state_column
|
|
37
|
+
)
|
|
38
|
+
Signoff::DSL.new(definition).instance_eval(&block)
|
|
39
|
+
definition.validate!
|
|
40
|
+
self.signoff_definition = definition
|
|
41
|
+
|
|
42
|
+
has_many :signoff_events,
|
|
43
|
+
-> { order(created_at: :asc, id: :asc) },
|
|
44
|
+
as: :workflowable,
|
|
45
|
+
class_name: "Signoff::Event",
|
|
46
|
+
inverse_of: :workflowable,
|
|
47
|
+
dependent: Signoff.configuration.dependent
|
|
48
|
+
|
|
49
|
+
_define_signoff_predicates(definition)
|
|
50
|
+
definition
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The definition, raising a friendly error if the macro was never called.
|
|
54
|
+
def signoff_definition!
|
|
55
|
+
signoff_definition ||
|
|
56
|
+
raise(NotConfiguredError,
|
|
57
|
+
"#{name} includes Signoff but never declared an " \
|
|
58
|
+
"`signoff do ... end` block")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def signoff_column
|
|
62
|
+
signoff_definition!.state_column
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def signoff_states
|
|
66
|
+
signoff_definition!.states
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# --- query scopes --------------------------------------------------
|
|
70
|
+
|
|
71
|
+
# Records currently in any of the given states.
|
|
72
|
+
def in_state(*names)
|
|
73
|
+
where(signoff_column => names.flatten.map(&:to_s))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Records that reached a successful terminal state.
|
|
77
|
+
def approved
|
|
78
|
+
in_state(*signoff_definition!.approval_terminal_states)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Records in the reject state.
|
|
82
|
+
def rejected
|
|
83
|
+
reject_state = signoff_definition!.reject_state
|
|
84
|
+
reject_state ? in_state(reject_state) : none
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Records that are neither approved nor rejected (still in flight).
|
|
88
|
+
def pending
|
|
89
|
+
definition = signoff_definition!
|
|
90
|
+
finalized = (definition.approval_terminal_states +
|
|
91
|
+
[definition.reject_state].compact).map(&:to_s)
|
|
92
|
+
finalized.empty? ? all : where.not(signoff_column => finalized)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def _define_signoff_predicates(definition)
|
|
96
|
+
reserved = %i[approved rejected pending]
|
|
97
|
+
definition.states.each do |state|
|
|
98
|
+
next if reserved.include?(state)
|
|
99
|
+
|
|
100
|
+
predicate = "#{state}?"
|
|
101
|
+
next if method_defined?(predicate) || private_method_defined?(predicate)
|
|
102
|
+
|
|
103
|
+
define_method(predicate) { current_state == state }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# --- state -----------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
# The current workflow state as a Symbol.
|
|
111
|
+
def current_state
|
|
112
|
+
definition = self.class.signoff_definition!
|
|
113
|
+
_ensure_state_column!(definition)
|
|
114
|
+
value = self[definition.state_column]
|
|
115
|
+
(value.presence || definition.initial_state).to_sym
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def approved?
|
|
119
|
+
self.class.signoff_definition!
|
|
120
|
+
.approval_terminal_states.include?(current_state)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def rejected?
|
|
124
|
+
current_state == self.class.signoff_definition!.reject_state
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def pending?
|
|
128
|
+
!approved? && !rejected?
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# --- transitions -----------------------------------------------------
|
|
132
|
+
|
|
133
|
+
# Advance the record forward and record a "submit" event. Conventionally
|
|
134
|
+
# used for the initial submission out of the draft state.
|
|
135
|
+
def submit!(user: nil, comment: nil, metadata: {}, to: nil,
|
|
136
|
+
ip_address: nil, user_agent: nil)
|
|
137
|
+
_advance!(action: "submit", to: to, user: user, comment: comment,
|
|
138
|
+
metadata: metadata, ip_address: ip_address, user_agent: user_agent)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Advance the record forward and record an "approve" event.
|
|
142
|
+
def approve!(user: nil, comment: nil, metadata: {}, to: nil,
|
|
143
|
+
ip_address: nil, user_agent: nil)
|
|
144
|
+
_advance!(action: "approve", to: to, user: user, comment: comment,
|
|
145
|
+
metadata: metadata, ip_address: ip_address, user_agent: user_agent)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Move the record to the configured reject state and record a "reject"
|
|
149
|
+
# event.
|
|
150
|
+
def reject!(user: nil, comment: nil, metadata: {},
|
|
151
|
+
ip_address: nil, user_agent: nil)
|
|
152
|
+
definition = self.class.signoff_definition!
|
|
153
|
+
unless definition.reject_state
|
|
154
|
+
raise InvalidTransitionError,
|
|
155
|
+
"no reject state configured for #{self.class.name}"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
from = current_state
|
|
159
|
+
raise InvalidTransitionError, "record is already rejected" if from == definition.reject_state
|
|
160
|
+
if definition.approval_terminal_states.include?(from)
|
|
161
|
+
raise InvalidTransitionError,
|
|
162
|
+
"cannot reject a finalized record (state: #{from})"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
_perform_transition!(action: "reject", from: from,
|
|
166
|
+
to: definition.reject_state, user: user,
|
|
167
|
+
comment: comment, metadata: metadata,
|
|
168
|
+
ip_address: ip_address, user_agent: user_agent)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# --- authorization predicates ---------------------------------------
|
|
172
|
+
|
|
173
|
+
def can_approve?(user = nil)
|
|
174
|
+
definition = self.class.signoff_definition!
|
|
175
|
+
from = current_state
|
|
176
|
+
# A no-argument approve! only proceeds when exactly one forward
|
|
177
|
+
# transition exists (zero => terminal, many => ambiguous).
|
|
178
|
+
return false unless definition.forward_targets(from).size == 1
|
|
179
|
+
|
|
180
|
+
_guard_satisfied?(from, user || Signoff::Current.user)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def can_reject?(user = nil)
|
|
184
|
+
definition = self.class.signoff_definition!
|
|
185
|
+
return false unless definition.reject_state
|
|
186
|
+
|
|
187
|
+
from = current_state
|
|
188
|
+
return false if from == definition.reject_state ||
|
|
189
|
+
definition.approval_terminal_states.include?(from)
|
|
190
|
+
|
|
191
|
+
_guard_satisfied?(from, user || Signoff::Current.user)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# --- audit helpers ---------------------------------------------------
|
|
195
|
+
|
|
196
|
+
# The full, chronologically ordered event history (preloadable via
|
|
197
|
+
# +includes(:signoff_events)+ to avoid N+1 queries).
|
|
198
|
+
def workflow_history
|
|
199
|
+
signoff_events
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def last_approval
|
|
203
|
+
_latest_event(conditions: { action: "approve" }) { |e| e.action == "approve" }
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def last_rejection
|
|
207
|
+
_latest_event(conditions: { action: "reject" }) { |e| e.action == "reject" }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# The user that moved the record into a successful terminal state.
|
|
211
|
+
def approved_by
|
|
212
|
+
states = self.class.signoff_definition!
|
|
213
|
+
.approval_terminal_states.map(&:to_s)
|
|
214
|
+
return nil if states.empty?
|
|
215
|
+
|
|
216
|
+
_latest_event(conditions: { to_state: states }) do |e|
|
|
217
|
+
states.include?(e.to_state)
|
|
218
|
+
end&.user
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
private
|
|
222
|
+
|
|
223
|
+
def _set_initial_approval_state
|
|
224
|
+
definition = self.class.signoff_definition
|
|
225
|
+
return unless definition
|
|
226
|
+
|
|
227
|
+
column = definition.state_column
|
|
228
|
+
return unless self.class.table_exists?
|
|
229
|
+
return unless self.class.column_names.include?(column.to_s)
|
|
230
|
+
return if self[column].present?
|
|
231
|
+
|
|
232
|
+
self[column] = definition.initial_state.to_s
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def _advance!(action:, to:, user:, comment:, metadata:, ip_address:, user_agent:)
|
|
236
|
+
definition = self.class.signoff_definition!
|
|
237
|
+
from = current_state
|
|
238
|
+
target = definition.next_state(from, to: to)
|
|
239
|
+
_perform_transition!(action: action, from: from, to: target, user: user,
|
|
240
|
+
comment: comment, metadata: metadata,
|
|
241
|
+
ip_address: ip_address, user_agent: user_agent)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def _perform_transition!(action:, from:, to:, user:, comment:, metadata:,
|
|
245
|
+
ip_address:, user_agent:)
|
|
246
|
+
if new_record?
|
|
247
|
+
raise InvalidTransitionError,
|
|
248
|
+
"persist #{self.class.name} before transitioning it"
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
definition = self.class.signoff_definition!
|
|
252
|
+
column = definition.state_column
|
|
253
|
+
user ||= Signoff::Current.user
|
|
254
|
+
_authorize_transition!(from: from, user: user)
|
|
255
|
+
|
|
256
|
+
event = nil
|
|
257
|
+
wrote = false
|
|
258
|
+
begin
|
|
259
|
+
self.class.transaction do
|
|
260
|
+
# Serialize concurrent transitions on this row with SELECT ... FOR
|
|
261
|
+
# UPDATE on just the state column, then re-assert the precondition
|
|
262
|
+
# against the locked value (the column is the single source of truth)
|
|
263
|
+
# so two racing approvals cannot both win. This locks without reloading
|
|
264
|
+
# the record, preserving in-memory/association state.
|
|
265
|
+
locked = self.class.where(self.class.primary_key => id).lock(true).pick(column)
|
|
266
|
+
actual = locked.presence&.to_s || definition.initial_state.to_s
|
|
267
|
+
unless actual == from.to_s
|
|
268
|
+
raise InvalidTransitionError,
|
|
269
|
+
"#{self.class.name} state changed concurrently " \
|
|
270
|
+
"(expected #{from}, was #{actual})"
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
_run_before_callbacks(definition, from, to)
|
|
274
|
+
_write_state!(definition, to)
|
|
275
|
+
wrote = true
|
|
276
|
+
event = _build_event(action: action, from: from, to: to, user: user,
|
|
277
|
+
comment: comment, metadata: metadata,
|
|
278
|
+
ip_address: ip_address, user_agent: user_agent)
|
|
279
|
+
event.save!
|
|
280
|
+
end
|
|
281
|
+
rescue StandardError
|
|
282
|
+
# Undo the optimistic in-memory state write if the transaction rolled
|
|
283
|
+
# back after we had already changed it.
|
|
284
|
+
self[column] = from.to_s if wrote
|
|
285
|
+
raise
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
_track_persisted_event(event)
|
|
289
|
+
_run_after_callbacks(definition, event)
|
|
290
|
+
event
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def _write_state!(definition, to)
|
|
294
|
+
column = definition.state_column
|
|
295
|
+
if Signoff.configuration.validate_on_transition
|
|
296
|
+
self[column] = to.to_s
|
|
297
|
+
save!
|
|
298
|
+
else
|
|
299
|
+
updates = { column => to.to_s }
|
|
300
|
+
updates[:updated_at] = Time.current if self.class.column_names.include?("updated_at")
|
|
301
|
+
update_columns(updates)
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def _authorize_transition!(from:, user:)
|
|
306
|
+
guard = self.class.signoff_definition!.guard_for(from)
|
|
307
|
+
return true unless guard
|
|
308
|
+
|
|
309
|
+
if user.nil?
|
|
310
|
+
raise UnauthorizedError,
|
|
311
|
+
"a user is required to perform a transition from #{from}"
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
unless _call_guard(guard, user)
|
|
315
|
+
raise UnauthorizedError,
|
|
316
|
+
"user is not authorized to transition from #{from}"
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
true
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def _guard_satisfied?(from, user)
|
|
323
|
+
guard = self.class.signoff_definition!.guard_for(from)
|
|
324
|
+
return true unless guard
|
|
325
|
+
return false if user.nil?
|
|
326
|
+
|
|
327
|
+
!!_call_guard(guard, user)
|
|
328
|
+
rescue StandardError
|
|
329
|
+
false
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def _call_guard(guard, user)
|
|
333
|
+
arity = guard.arity
|
|
334
|
+
if arity == 2 || arity <= -2
|
|
335
|
+
guard.call(user, self)
|
|
336
|
+
else
|
|
337
|
+
guard.call(user)
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def _build_event(action:, from:, to:, user:, comment:, metadata:,
|
|
342
|
+
ip_address:, user_agent:)
|
|
343
|
+
configuration = Signoff.configuration
|
|
344
|
+
attributes = {
|
|
345
|
+
action: action.to_s,
|
|
346
|
+
from_state: from&.to_s,
|
|
347
|
+
to_state: to.to_s,
|
|
348
|
+
comment: comment,
|
|
349
|
+
metadata: metadata || {}
|
|
350
|
+
}
|
|
351
|
+
attributes[:user_id] = _user_identifier(user) unless user.nil?
|
|
352
|
+
|
|
353
|
+
if configuration.track_ip_addresses
|
|
354
|
+
ip = ip_address || Signoff::Current.ip_address
|
|
355
|
+
attributes[:ip_address] = ip unless ip.nil?
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
if configuration.store_user_agent
|
|
359
|
+
agent = user_agent || Signoff::Current.user_agent
|
|
360
|
+
attributes[:user_agent] = agent unless agent.nil?
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Built standalone (not via the association) so a rolled-back transaction
|
|
364
|
+
# never leaves a phantom event in a loaded association cache.
|
|
365
|
+
Signoff::Event.new(attributes.merge(workflowable: self))
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def _user_identifier(user)
|
|
369
|
+
user.respond_to?(:id) ? user.id : user
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Keep a loaded association cache consistent after a successful commit so
|
|
373
|
+
# read-after-write on a preloaded record stays correct.
|
|
374
|
+
def _track_persisted_event(event)
|
|
375
|
+
association = self.association(:signoff_events)
|
|
376
|
+
association.add_to_target(event) if association.loaded?
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# Return the most recent matching event. Uses the in-memory collection when
|
|
380
|
+
# the association is already loaded (so +includes(:signoff_events)+
|
|
381
|
+
# eliminates N+1 queries), otherwise issues a targeted query.
|
|
382
|
+
def _latest_event(conditions:, &predicate)
|
|
383
|
+
association = self.association(:signoff_events)
|
|
384
|
+
if association.loaded?
|
|
385
|
+
association.target.select(&predicate).max_by { |e| [e.created_at, e.id] }
|
|
386
|
+
else
|
|
387
|
+
signoff_events
|
|
388
|
+
.where(conditions)
|
|
389
|
+
.reorder(created_at: :desc, id: :desc)
|
|
390
|
+
.first
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def _run_before_callbacks(definition, from, to)
|
|
395
|
+
definition.before_callbacks.each do |callback|
|
|
396
|
+
_invoke_callback(callback, [self, from, to])
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def _run_after_callbacks(definition, event)
|
|
401
|
+
definition.after_callbacks.each do |callback|
|
|
402
|
+
_invoke_callback(callback, [self, event])
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def _invoke_callback(callback, args)
|
|
407
|
+
if callback.arity.negative?
|
|
408
|
+
callback.call(*args)
|
|
409
|
+
else
|
|
410
|
+
callback.call(*args.first(callback.arity))
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def _ensure_state_column!(definition)
|
|
415
|
+
column = definition.state_column.to_s
|
|
416
|
+
return if self.class.column_names.include?(column)
|
|
417
|
+
|
|
418
|
+
raise MissingColumnError,
|
|
419
|
+
"#{self.class.name} is missing the #{column.inspect} state column. " \
|
|
420
|
+
"Run `rails g signoff:model #{self.class.name}` or add a " \
|
|
421
|
+
"string column named #{column.inspect}."
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
end
|
data/lib/signoff.rb
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support"
|
|
4
|
+
require "active_support/concern"
|
|
5
|
+
require "active_record"
|
|
6
|
+
|
|
7
|
+
require_relative "signoff/version"
|
|
8
|
+
require_relative "signoff/errors"
|
|
9
|
+
require_relative "signoff/configuration"
|
|
10
|
+
require_relative "signoff/definition"
|
|
11
|
+
require_relative "signoff/dsl"
|
|
12
|
+
require_relative "signoff/current"
|
|
13
|
+
require_relative "signoff/model"
|
|
14
|
+
require_relative "signoff/controller"
|
|
15
|
+
require_relative "signoff/engine" if defined?(::Rails::Engine)
|
|
16
|
+
|
|
17
|
+
# Signoff adds concurrency-safe approval workflows with an immutable audit
|
|
18
|
+
# trail to ActiveRecord models.
|
|
19
|
+
#
|
|
20
|
+
# class ExpenseReport < ApplicationRecord
|
|
21
|
+
# include Signoff
|
|
22
|
+
#
|
|
23
|
+
# signoff do
|
|
24
|
+
# state :draft
|
|
25
|
+
# state :manager_review
|
|
26
|
+
# state :approved
|
|
27
|
+
# state :rejected
|
|
28
|
+
#
|
|
29
|
+
# transition :draft, to: :manager_review
|
|
30
|
+
# transition :manager_review, to: :approved
|
|
31
|
+
#
|
|
32
|
+
# reject_to :rejected
|
|
33
|
+
# end
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
# Including the module mixes in Signoff::Model (the +signoff+
|
|
37
|
+
# macro, transition methods, scopes and audit helpers). The same namespace also
|
|
38
|
+
# holds the configuration, the audit Event model and the supporting classes.
|
|
39
|
+
module Signoff
|
|
40
|
+
extend ActiveSupport::Concern
|
|
41
|
+
include Signoff::Model
|
|
42
|
+
|
|
43
|
+
class << self
|
|
44
|
+
# The current global configuration.
|
|
45
|
+
def configuration
|
|
46
|
+
@configuration ||= Configuration.new
|
|
47
|
+
end
|
|
48
|
+
alias_method :config, :configuration
|
|
49
|
+
|
|
50
|
+
# Configure the gem, typically from an initializer:
|
|
51
|
+
#
|
|
52
|
+
# Signoff.configure do |config|
|
|
53
|
+
# config.user_class = "User"
|
|
54
|
+
# config.track_ip_addresses = true
|
|
55
|
+
# end
|
|
56
|
+
def configure
|
|
57
|
+
yield(configuration)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Reset configuration to defaults (primarily useful in tests).
|
|
61
|
+
def reset_configuration!
|
|
62
|
+
@configuration = Configuration.new
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: signoff
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Jijo Bose
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activerecord
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.1'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '9.1'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '7.1'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '9.1'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: activesupport
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '7.1'
|
|
39
|
+
- - "<"
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '9.1'
|
|
42
|
+
type: :runtime
|
|
43
|
+
prerelease: false
|
|
44
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '7.1'
|
|
49
|
+
- - "<"
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '9.1'
|
|
52
|
+
- !ruby/object:Gem::Dependency
|
|
53
|
+
name: pg
|
|
54
|
+
requirement: !ruby/object:Gem::Requirement
|
|
55
|
+
requirements:
|
|
56
|
+
- - ">="
|
|
57
|
+
- !ruby/object:Gem::Version
|
|
58
|
+
version: '1.1'
|
|
59
|
+
type: :runtime
|
|
60
|
+
prerelease: false
|
|
61
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
62
|
+
requirements:
|
|
63
|
+
- - ">="
|
|
64
|
+
- !ruby/object:Gem::Version
|
|
65
|
+
version: '1.1'
|
|
66
|
+
- !ruby/object:Gem::Dependency
|
|
67
|
+
name: railties
|
|
68
|
+
requirement: !ruby/object:Gem::Requirement
|
|
69
|
+
requirements:
|
|
70
|
+
- - ">="
|
|
71
|
+
- !ruby/object:Gem::Version
|
|
72
|
+
version: '7.1'
|
|
73
|
+
- - "<"
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '9.1'
|
|
76
|
+
type: :runtime
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - ">="
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '7.1'
|
|
83
|
+
- - "<"
|
|
84
|
+
- !ruby/object:Gem::Version
|
|
85
|
+
version: '9.1'
|
|
86
|
+
description: |
|
|
87
|
+
Signoff adds convention-over-configuration approval workflows to
|
|
88
|
+
ActiveRecord models. Declare states and transitions with a simple DSL, get
|
|
89
|
+
submit!/approve!/reject! methods, authorization hooks, query scopes and an
|
|
90
|
+
immutable PostgreSQL JSONB audit trail - no external services required.
|
|
91
|
+
email:
|
|
92
|
+
- bosejijo@gmail.com
|
|
93
|
+
executables: []
|
|
94
|
+
extensions: []
|
|
95
|
+
extra_rdoc_files: []
|
|
96
|
+
files:
|
|
97
|
+
- CHANGELOG.md
|
|
98
|
+
- LICENSE.txt
|
|
99
|
+
- README.md
|
|
100
|
+
- app/models/signoff/event.rb
|
|
101
|
+
- lib/generators/signoff/install/USAGE
|
|
102
|
+
- lib/generators/signoff/install/install_generator.rb
|
|
103
|
+
- lib/generators/signoff/install/templates/initializer.rb
|
|
104
|
+
- lib/generators/signoff/install/templates/migration.rb.tt
|
|
105
|
+
- lib/generators/signoff/model/USAGE
|
|
106
|
+
- lib/generators/signoff/model/model_generator.rb
|
|
107
|
+
- lib/generators/signoff/model/templates/migration.rb.tt
|
|
108
|
+
- lib/signoff.rb
|
|
109
|
+
- lib/signoff/configuration.rb
|
|
110
|
+
- lib/signoff/controller.rb
|
|
111
|
+
- lib/signoff/current.rb
|
|
112
|
+
- lib/signoff/definition.rb
|
|
113
|
+
- lib/signoff/dsl.rb
|
|
114
|
+
- lib/signoff/engine.rb
|
|
115
|
+
- lib/signoff/errors.rb
|
|
116
|
+
- lib/signoff/model.rb
|
|
117
|
+
- lib/signoff/version.rb
|
|
118
|
+
homepage: https://github.com/JijoBose/Signoff
|
|
119
|
+
licenses:
|
|
120
|
+
- MIT
|
|
121
|
+
metadata:
|
|
122
|
+
source_code_uri: https://github.com/JijoBose/Signoff
|
|
123
|
+
changelog_uri: https://github.com/JijoBose/Signoff/blob/main/CHANGELOG.md
|
|
124
|
+
bug_tracker_uri: https://github.com/JijoBose/Signoff/issues
|
|
125
|
+
rubygems_mfa_required: 'true'
|
|
126
|
+
rdoc_options: []
|
|
127
|
+
require_paths:
|
|
128
|
+
- lib
|
|
129
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
130
|
+
requirements:
|
|
131
|
+
- - ">="
|
|
132
|
+
- !ruby/object:Gem::Version
|
|
133
|
+
version: 3.2.0
|
|
134
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
135
|
+
requirements:
|
|
136
|
+
- - ">="
|
|
137
|
+
- !ruby/object:Gem::Version
|
|
138
|
+
version: '0'
|
|
139
|
+
requirements: []
|
|
140
|
+
rubygems_version: 4.0.13
|
|
141
|
+
specification_version: 4
|
|
142
|
+
summary: Concurrency-safe approval workflows for ActiveRecord; immutable audit trail.
|
|
143
|
+
test_files: []
|