draft_approve 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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