draft_approve 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.
@@ -0,0 +1,80 @@
1
+ require 'draft_approve/persistor'
2
+
3
+ module DraftApprove
4
+ module Draftable
5
+
6
+ # Instance methods automatically added to an ActiveRecord model when
7
+ # +acts_as_draftable+ is called
8
+ module InstanceMethods
9
+ ##### Basic DraftApprove instance methods #####
10
+
11
+ # Whether this object is draftable. Helper method to identify draftable
12
+ # objects.
13
+ #
14
+ # @return [Boolean] true
15
+ def draftable?
16
+ true
17
+ end
18
+
19
+ # Saves any changes to the object as a draft.
20
+ #
21
+ # This method may be called both on a new object which has not been
22
+ # persisted yet, and on objects which have already been persisted.
23
+ #
24
+ # @param options [Hash] the options to save the draft with
25
+ # @option options [Symbol] :validate whether to validate the model before
26
+ # draft changes are saved, defaults to +true+
27
+ # @option options [Symbol] :create_method the method to use when creating
28
+ # a new object from this draft, eg. +:find_or_create_by!+. This must be
29
+ # a method available on the object, and must accept a hash of attribute
30
+ # names to attribute values. The default is +create!+. Ignored if this
31
+ # draft is for an object which has already been persisted.
32
+ # @option options [Symbol] :update_method the method to use when updating
33
+ # an existing object from this draft, eg. +:update_columns+. This must
34
+ # be a method available on the object, and must accept a hash of
35
+ # attribute names to attribute values. The default is +update!+. Ignored
36
+ # if this draft is for an object which has not yet been persisted.
37
+ #
38
+ # @return [Draft, nil] the +Draft+ object which was created, or +nil+ if
39
+ # there were no changes to the object
40
+ def draft_save!(options = nil)
41
+ if self.new_record?
42
+ DraftApprove::Persistor.write_draft_from_model(Draft::CREATE, self, options)
43
+ else
44
+ DraftApprove::Persistor.write_draft_from_model(Draft::UPDATE, self, options)
45
+ end
46
+ end
47
+
48
+ # Marks this object to be destroyed when this draft change is approved.
49
+ #
50
+ # This method should only be called on objects which have already been
51
+ # persisted.
52
+ #
53
+ # @param options [Hash] the options to save the draft with
54
+ # @option options [Symbol] :delete_method the method to use to delete
55
+ # the object when this draft is approved, eg. +:delete+. This must be
56
+ # a method available on the object. The default is +destroy!+.
57
+ #
58
+ # @return [Draft] the +Draft+ object which was created
59
+ def draft_destroy!(options = nil)
60
+ DraftApprove::Persistor.write_draft_from_model(Draft::DELETE, self, options)
61
+ end
62
+
63
+ ##### Additional convenience DraftApprove instance methods #####
64
+
65
+ # Updates an existing object with the given attributes, and saves the
66
+ # updates as a draft.
67
+ #
68
+ # @param attributes [Hash] a hash of attribute names to attribute values,
69
+ # like the hash expected by the ActiveRecord +update+ / +update!+
70
+ # methods
71
+ #
72
+ # @return [Draft, nil] the +Draft+ object which was created, or +nil+ if
73
+ # there were no changes to the object
74
+ def draft_update!(attributes)
75
+ self.assign_attributes(attributes)
76
+ self.draft_save!
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,19 @@
1
+ module DraftApprove
2
+ module Errors
3
+ class DraftTransactionError < StandardError; end
4
+ class NestedDraftTransactionError < DraftTransactionError; end
5
+ class NoDraftTransactionError < DraftTransactionError; end
6
+
7
+ class DraftSaveError < StandardError; end
8
+ class ExistingDraftError < DraftSaveError; end
9
+ class AlreadyPersistedModelError < DraftSaveError; end
10
+ class UnpersistedModelError < DraftSaveError; end
11
+
12
+ class ChangeSerializationError < StandardError; end
13
+ class AssociationUnsavedError < ChangeSerializationError; end
14
+
15
+ class ApplyDraftChangesError < StandardError; end
16
+ class NoDraftableError < ApplyDraftChangesError; end
17
+ class PriorDraftNotAppliedError < ApplyDraftChangesError; end
18
+ end
19
+ end
@@ -0,0 +1,75 @@
1
+ # @note It is strongly recommended that you do not directly create +Draft+
2
+ # objects, and instead use the supported public interface for doing so. See
3
+ # +DraftApprove::Draftable::ClassMethods+,
4
+ # +DraftApprove::Draftable::InstanceMethods+, and the README docs for this.
5
+ #
6
+ # ActiveRecord model for persisting data about draft changes.
7
+ #
8
+ # Each +Draft+ must be linked to a +DraftTransaction+, and must have a
9
+ # +draft_action_type+ which specifies whether this draft is to create a new
10
+ # record, update a record, or delete a record.
11
+ #
12
+ # If the draft is to update or delete an existing record in the database, the
13
+ # +Draft+ will also have a link to the +acts_as_draftable+ instance to which it
14
+ # relates, via the polymorphic +draftable+ association.
15
+ #
16
+ # Linking to the +acts_as_draftable+ instance is not possible for drafts which
17
+ # create new records, since the new record does not yet exist in the database!
18
+ # In these cases, the +draftable_type+ column is still set to the name of the
19
+ # class which is to be created, but the +draftable_id+ is +nil+.
20
+ #
21
+ # The +draft_changes+ attribute is a serialized representation of the draft
22
+ # changes. The representation is delegated to a +DraftApprove::Serialization+
23
+ # module. At present, there is only a JSON implementation, suitable for use with
24
+ # PostgreSQL databases.
25
+ #
26
+ # Note that saving 'no-op' +Draft+s is generally avoided by this library
27
+ # (specifically by the +DraftApprove::Persistor+ class).
28
+ #
29
+ # @see DraftTransaction
30
+ # @see DraftApprove::Draftable::ClassMethods
31
+ # @see DraftApprove::Draftable::InstanceMethods
32
+ class Draft < ActiveRecord::Base
33
+ # IMPORTANT NOTE: These constants are written to the database, so cannot be
34
+ # updated without requiring a migration of existing draft data
35
+ CREATE = 'create'.freeze
36
+ UPDATE = 'update'.freeze
37
+ DELETE = 'delete'.freeze
38
+
39
+ belongs_to :draft_transaction
40
+ belongs_to :draftable, polymorphic: true, optional: true
41
+
42
+ validates :draft_action_type, inclusion: {
43
+ in: [CREATE, UPDATE, DELETE],
44
+ message: "%{value} is not a valid Draft.draft_action_type"
45
+ }
46
+
47
+ scope :pending_approval, -> { joins(:draft_transaction).merge(DraftTransaction.pending_approval) }
48
+ scope :approved, -> { joins(:draft_transaction).merge(DraftTransaction.approved) }
49
+ scope :rejected, -> { joins(:draft_transaction).merge(DraftTransaction.rejected) }
50
+ scope :approval_error, -> { joins(:draft_transaction).merge(DraftTransaction.approval_error) }
51
+
52
+ # @return [Boolean] +true+ if this +Draft+ is to create a new record, +false+
53
+ # otherwise
54
+ def create?
55
+ draft_action_type == CREATE
56
+ end
57
+
58
+ # @return [Boolean] +true+ if this +Draft+ is to update an existing record,
59
+ # +false+ otherwise
60
+ def update?
61
+ draft_action_type == UPDATE
62
+ end
63
+
64
+ # @return [Boolean] +true+ if this +Draft+ is to delete an existing record,
65
+ # +false+ otherwise
66
+ def delete?
67
+ draft_action_type == DELETE
68
+ end
69
+
70
+ # Apply the changes in this draft, writing them to the database
71
+ # @api private
72
+ def apply_changes!
73
+ DraftApprove::Persistor.write_model_from_draft(self)
74
+ end
75
+ end
@@ -0,0 +1,109 @@
1
+ # @note It is strongly recommended that you do not directly create
2
+ # +DraftTransaction+ objects, and instead use the supported public interface
3
+ # for doing so. See +DraftApprove::Draftable::ClassMethods+ and the README
4
+ # docs for this.
5
+ #
6
+ # ActiveRecord model for persisting data about a group of draft changes which
7
+ # should be approved or rejected as a single, transactional group.
8
+ #
9
+ # Each +DraftTransaction+ has many linked +Draft+ objects.
10
+ #
11
+ # When a +DraftTransaction+ is first created, it has +status+ of
12
+ # +pending_approval+. The changes should then be reviewed and either approved or
13
+ # rejected.
14
+ #
15
+ # +DraftTransaction+ objects also have optional attribute for storing who or
16
+ # what created the transaction and the group of draft changes (+created_by+
17
+ # attribute), who or what reviewed the changes (+reviewed_by+ attribute), and
18
+ # the reason given for approving or rejecting the changes (+review_reason+
19
+ # attribute), and finally the stack trace of any error which occurred during
20
+ # the process of applying the changes (+error+ attribute).
21
+ #
22
+ # Arbitrary extra data can also be stored in the +extra_data+ attribute.
23
+ #
24
+ # Note that saving 'no-op' +DraftTransaction+s is generally avoided by this
25
+ # library (specifically by the +DraftApprove::Transaction+ class).
26
+ #
27
+ # @see Draft
28
+ # @see DraftApprove::Draftable::ClassMethods
29
+ class DraftTransaction < ActiveRecord::Base
30
+ # IMPORTANT NOTE: These constants are written to the database, so cannot be
31
+ # updated without requiring a migration of existing draft data
32
+ PENDING_APPROVAL = 'pending_approval'.freeze
33
+ APPROVED = 'approved'.freeze
34
+ REJECTED = 'rejected'.freeze
35
+ APPROVAL_ERROR = 'approval_error'.freeze
36
+
37
+ has_many :drafts
38
+
39
+ validates :status, inclusion: {
40
+ in: [PENDING_APPROVAL, APPROVED, REJECTED, APPROVAL_ERROR],
41
+ message: "%{value} is not a valid DraftTransaction.status"
42
+ }
43
+
44
+ scope :pending_approval, -> { where(status: PENDING_APPROVAL) }
45
+ scope :approved, -> { where(status: APPROVED) }
46
+ scope :rejected, -> { where(status: REJECTED) }
47
+ scope :approval_error, -> { where(status: APPROVAL_ERROR) }
48
+
49
+ # Approve all changes in this +DraftTransaction+ and immediately apply them
50
+ # to the database.
51
+ #
52
+ # Note that applying the changes occurs within a database transaction.
53
+ #
54
+ # @param reviewed_by [String] the user or process which approved these changes
55
+ # @param review_reason [String] the reason for approving these changes
56
+ #
57
+ # @return [Boolean] +true+ if the changes were successfully applied
58
+ def approve_changes!(reviewed_by: nil, review_reason: nil)
59
+ begin
60
+ ActiveRecord::Base.transaction do
61
+ self.lock! # Avoid multiple threads applying changes concurrently
62
+ return false unless self.status == PENDING_APPROVAL
63
+
64
+ drafts.order(:created_at, :id).each do |draft|
65
+ draft.apply_changes!
66
+ end
67
+
68
+ self.update!(status: APPROVED, reviewed_by: reviewed_by, review_reason: review_reason)
69
+ return true
70
+ end
71
+ rescue StandardError => e
72
+ # Log the error in the database table and re-raise
73
+ self.update!(status: APPROVAL_ERROR, error: "#{e.inspect}\n#{e.backtrace.join("\n")}")
74
+ raise
75
+ end
76
+ end
77
+
78
+ # Reject all changes in this +DraftTransaction+.
79
+ #
80
+ # @param reviewed_by [String] the user or process which rejected these changes
81
+ # @param review_reason [String] the reason for rejecting these changes
82
+ #
83
+ # @return [Boolean] +true+ if the changes were successfully rejected
84
+ def reject_changes!(reviewed_by: nil, review_reason: nil)
85
+ self.update!(status: REJECTED, reviewed_by: reviewed_by, review_reason: review_reason)
86
+ return true
87
+ end
88
+
89
+ # Get a +DraftChangesProxy+ for the given object in the scope of this
90
+ # +DraftTransaction+.
91
+ #
92
+ # @param object [Object] the +Draft+ or +acts_as_draftable+ object to
93
+ # create a +DraftChangesProxy+ for
94
+ #
95
+ # @return [DraftChangesProxy] a proxy to get changes drafted to the given
96
+ # object and related objects, within the scope of this +DraftTransaction+
97
+ #
98
+ # @see DraftApprove::DraftChangesProxy
99
+ def draft_proxy_for(object)
100
+ serialization_module.get_draft_changes_proxy.new(object, self)
101
+ end
102
+
103
+ # @return the module used for serialization by this +DraftTransaction+.
104
+ #
105
+ # @api private
106
+ def serialization_module
107
+ Object.const_get(self.serialization)
108
+ end
109
+ end
@@ -0,0 +1,167 @@
1
+ require 'draft_approve/errors'
2
+ require 'draft_approve/models/draft'
3
+ require 'draft_approve/serialization/json'
4
+
5
+ module DraftApprove
6
+
7
+ # Logic for writing a +Draft+ to the database, and for applying changes
8
+ # contained within a single +Draft+ and saving them to the database.
9
+ #
10
+ # @api private
11
+ class Persistor
12
+ DEFAULT_CREATE_METHOD = 'create!'.freeze
13
+ DEFAULT_UPDATE_METHOD = 'update!'.freeze
14
+ DEFAULT_DELETE_METHOD = 'destroy!'.freeze
15
+ private_constant :DEFAULT_CREATE_METHOD, :DEFAULT_UPDATE_METHOD, :DEFAULT_DELETE_METHOD
16
+
17
+ # IMPORTANT NOTE: These constants are written to the database, so cannot be
18
+ # updated without requiring a migration of existing draft data. Such a
19
+ # migration may be very slow, since these constants are embedded in the
20
+ # JSON generated by this serializer!
21
+ CREATE_METHOD = 'create_method'.freeze
22
+ UPDATE_METHOD = 'update_method'.freeze
23
+ DELETE_METHOD = 'delete_method'.freeze
24
+ private_constant :CREATE_METHOD, :UPDATE_METHOD, :DELETE_METHOD
25
+
26
+ # Write a +Draft+ object to the database to persist any changes to the
27
+ # given model.
28
+ #
29
+ # @param action_type [String] the type of action this draft will represent -
30
+ # +CREATE+, +UPDATE+, or +DELETE+
31
+ # @param model [Object] the +acts_as_draftable+ ActiveRecord model whose
32
+ # changes will be saved to the database
33
+ # @param options [Hash] the options to use when saving this draft, see
34
+ # +DraftApprove::Draftable::InstanceMethods#draft_save!+ and
35
+ # +DraftApprove::Draftable::InstanceMethods#draft_destroy!+ for details of
36
+ # valid options
37
+ #
38
+ # @return [Draft, false] the +Draft+ record which was created, or +false+ if
39
+ # there were no changes (ie. the result would have been a 'no-op' change)
40
+ #
41
+ # @see DraftApprove::Draftable::InstanceMethods#draft_save!
42
+ # @see DraftApprove::Draftable::InstanceMethods#draft_destroy!
43
+ def self.write_draft_from_model(action_type, model, options = nil)
44
+ raise(ArgumentError, 'model argument must be present') unless model.present?
45
+
46
+ if validate_model?(options) && model.invalid?
47
+ raise(ActiveRecord::RecordInvalid, model)
48
+ end
49
+
50
+ DraftApprove::Transaction.ensure_in_draft_transaction do
51
+ # Now we're in a Transaction, ensure we don't get multiple drafts for the same object
52
+ if model.persisted? && Draft.pending_approval.where(draftable: model).count > 0
53
+ raise(DraftApprove::Errors::ExistingDraftError, "#{model} has existing draft")
54
+ end
55
+
56
+ case action_type
57
+ when Draft::CREATE
58
+ raise(DraftApprove::Errors::AlreadyPersistedModelError, "#{model} is already persisted") if model.persisted?
59
+ draftable_type = model.class.name
60
+ draftable_id = nil
61
+ when Draft::UPDATE
62
+ raise(DraftApprove::Errors::UnpersistedModelError, "#{model} isn't persisted") unless model.persisted?
63
+ draftable_type = model.class.name
64
+ draftable_id = model.id
65
+ when Draft::DELETE
66
+ raise(DraftApprove::Errors::UnpersistedModelError, "#{model} isn't persisted") unless model.persisted?
67
+ draftable_type = model.class.name
68
+ draftable_id = model.id
69
+ else
70
+ raise(ArgumentError, "Unknown draft_action_type #{action_type}")
71
+ end
72
+
73
+ draft_transaction = DraftApprove::Transaction.current_draft_transaction!
74
+ draft_options = sanitize_options_for_db(options)
75
+ serializer = serializer_class(draft_transaction)
76
+ changes = serializer.changes_for_model(model)
77
+
78
+ # Don't write no-op updates!
79
+ return false if changes.empty? && action_type == Draft::UPDATE
80
+
81
+ return model.draft_pending_approval = Draft.create!(
82
+ draft_transaction: draft_transaction,
83
+ draftable_type: draftable_type,
84
+ draftable_id: draftable_id,
85
+ draft_action_type: action_type,
86
+ draft_changes: changes,
87
+ draft_options: draft_options
88
+ )
89
+ end
90
+ end
91
+
92
+ # Write the changes represented by the given +Draft+ object to the database.
93
+ #
94
+ # Depending upon the type of +Draft+, this method may create a new record in
95
+ # the database, update an existing record, or delete a record.
96
+ #
97
+ # @param draft [Draft] the +Draft+ object whose changes should be applied
98
+ # and persisted to the database
99
+ #
100
+ # @return [Object] the +acts_as_draftable+ ActiveRecord model which has been
101
+ # created, updated, or deleted
102
+ def self.write_model_from_draft(draft)
103
+ serializer = serializer_class(draft.draft_transaction)
104
+ new_values_hash = serializer.new_values_for_draft(draft)
105
+ options = draft.draft_options || {}
106
+
107
+ case draft.draft_action_type
108
+ when Draft::CREATE
109
+ raise(DraftApprove::Errors::NoDraftableError, "No draftable_type for #{draft}") if draft.draftable_type.blank?
110
+
111
+ create_method = (options.include?(CREATE_METHOD) ? options[CREATE_METHOD] : DEFAULT_CREATE_METHOD)
112
+
113
+ model_class = Object.const_get(draft.draftable_type)
114
+ model = model_class.send(create_method, new_values_hash)
115
+
116
+ # We've only just persisted the model, the draft can't have referenced it before!
117
+ draft.update!(draftable: model)
118
+
119
+ return model
120
+ when Draft::UPDATE
121
+ raise(DraftApprove::Errors::NoDraftableError, "No draftable for #{draft}") if draft.draftable.blank?
122
+
123
+ update_method = (options.include?(UPDATE_METHOD) ? options[UPDATE_METHOD] : DEFAULT_UPDATE_METHOD)
124
+
125
+ model = draft.draftable
126
+ model.send(update_method, new_values_hash)
127
+ return model
128
+ when Draft::DELETE
129
+ raise(DraftApprove::Errors::NoDraftableError, "No draftable for #{draft}") if draft.draftable.blank?
130
+
131
+ delete_method = (options.include?(DELETE_METHOD) ? options[DELETE_METHOD] : DEFAULT_DELETE_METHOD)
132
+
133
+ model = draft.draftable
134
+ model.send(delete_method)
135
+ return model
136
+ else
137
+ raise(ArgumentError, "Unknown draft_action_type #{draft.draft_action_type}")
138
+ end
139
+ end
140
+
141
+ private
142
+
143
+ # Helper to determine whether to validate a model before writing a draft
144
+ def self.validate_model?(options)
145
+ options ||= {}
146
+ options.fetch(:validate, true)
147
+ end
148
+
149
+ # Helper to get the serialization class to use
150
+ def self.serializer_class(draft_transaction)
151
+ draft_transaction.serialization_module.get_serializer
152
+ end
153
+
154
+ # Helper to remove invalid options before they get persisted to the database
155
+ def self.sanitize_options_for_db(options)
156
+ return nil if !options || options.empty?
157
+
158
+ draft_options_keys = [CREATE_METHOD, UPDATE_METHOD, DELETE_METHOD]
159
+
160
+ accepted_options = options.each_with_object({}) do |(key, value), accepted_opts|
161
+ accepted_opts[key.to_s] = value if draft_options_keys.include?(key.to_s)
162
+ end
163
+
164
+ return (accepted_options.empty? ? nil : accepted_options)
165
+ end
166
+ end
167
+ end