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.
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "draft_approve"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,56 @@
1
+
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'draft_approve/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'draft_approve'
8
+ spec.version = DraftApprove::VERSION
9
+ spec.authors = ['Andrew Sibley']
10
+ spec.email = ['andrew.s@38degrees.org.uk']
11
+ spec.license = 'MIT'
12
+ spec.homepage = 'https://github.com/38dgs/draft_approve'
13
+ spec.summary = %q{Save drafts of ActiveRecord models & approve them to apply the changes.}
14
+ spec.description = %q{
15
+ All draft data is saved in a separate table, so no need to worry about
16
+ existing code / SQL accidentally finding non-approved data. Supports draft
17
+ changes to existing objects, and creating new objects as drafts. Supports
18
+ 'draft transactions' which may update / create many objects, and must be
19
+ approved / rejected in their entirety.
20
+ }
21
+
22
+ # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
23
+ # to allow pushing to a single host or delete this section to allow pushing to any host.
24
+ # if spec.respond_to?(:metadata)
25
+ # spec.metadata['allowed_push_host'] = "TODO: Set to 'http://mygemserver.com'"
26
+ #
27
+ # spec.metadata['homepage_uri'] = spec.homepage
28
+ # spec.metadata['source_code_uri'] = 'https://github.com/38dgs/draft_approve'
29
+ # spec.metadata['changelog_uri'] = 'https://github.com/38dgs/draft_approve/CHANGELOG.md'
30
+ # else
31
+ # raise 'RubyGems 2.0 or newer is required to protect against public gem pushes.'
32
+ # end
33
+
34
+ # Specify which files should be added to the gem when it is released.
35
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
36
+ spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
37
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
38
+ end
39
+ spec.bindir = "exe"
40
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
41
+ spec.require_paths = ["lib"]
42
+
43
+ spec.add_dependency "activerecord", "~> 5.2"
44
+
45
+ spec.add_development_dependency "bundler", "~> 1.17"
46
+ spec.add_development_dependency "rake", "~> 10.0"
47
+ spec.add_development_dependency "rspec", "~> 3.0"
48
+ spec.add_development_dependency "database_cleaner", "~> 1.7"
49
+ spec.add_development_dependency "sqlite3", "~> 1.3"
50
+ spec.add_development_dependency "pg", ">= 0.18", "< 2.0"
51
+ spec.add_development_dependency "factory_bot", "~> 4.11"
52
+ spec.add_development_dependency "codecov", "~> 0.1"
53
+ spec.add_development_dependency "appraisal", "~> 2.2"
54
+ spec.add_development_dependency "pry", "~> 0.12"
55
+ spec.add_development_dependency "yard", "~> 0.9.18"
56
+ end
@@ -0,0 +1,5 @@
1
+ require 'active_record'
2
+
3
+ require 'draft_approve/draftable/base_class_methods'
4
+
5
+ ActiveRecord::Base.extend DraftApprove::Draftable::BaseClassMethods
@@ -0,0 +1,242 @@
1
+ module DraftApprove
2
+
3
+ # Mixin wrapper for +Draft+ and +acts_as_draftable+ objects, such that both
4
+ # have a consistent API to get current and new values within the context of
5
+ # a specific +DraftTransaction+.
6
+ #
7
+ # References to other objects returned by methods from this class are also
8
+ # wrapped in a +DraftChangesProxy+, meaning it is relatively easy to chain
9
+ # and navigate complex association trees within the context of a
10
+ # +DraftTransaction+.
11
+ #
12
+ # This can be useful, for example, to display all changes that will occur
13
+ # on an object, including changes to all it's associated 'child' objects.
14
+ #
15
+ # It is often most convenient to use the
16
+ # +DraftTransaction#draft_proxy_for+ method to construct a
17
+ # +DraftApproveProxy+ instance. This will ensure the correct implementation
18
+ # of +DraftApproveProxy+ is used.
19
+ #
20
+ # Classes which include this module must implement the instance methods
21
+ # +new_value+, +association_changed?+, +associations_added+,
22
+ # +associations_updated+, +associations_removed+.
23
+ #
24
+ # @see DraftTransaction#draft_proxy_for
25
+ module DraftChangesProxy
26
+ attr_reader :draft, :draftable, :draftable_class, :draft_transaction
27
+
28
+ # Creates a new DraftChangesProxy
29
+ #
30
+ # @param object [Object] the +Draft+ object, or the instance of an
31
+ # +acts_as_draftable+ class, which is being proxied to get changes
32
+ # @param transaction [DraftTransaction] the +DraftTransaction+ within
33
+ # which to look for changes. If +object+ is a +Draft+, this parameter
34
+ # is optional and if not provided will use the +DraftTransaction+
35
+ # associated with the given +Draft+. If +object+ is not a +Draft+,
36
+ # this parameter is required.
37
+ def initialize(object, transaction = nil)
38
+ if object.blank?
39
+ raise(ArgumentError, "object is required")
40
+ end
41
+
42
+ if object.new_record?
43
+ raise(ArgumentError, "object #{object} must already be persisted")
44
+ end
45
+
46
+ if object.is_a? Draft
47
+ if transaction.present? && object.draft_transaction != transaction
48
+ raise(ArgumentError, "draft_transaction for #{object} is inconsistent with given draft_transaction #{transaction}")
49
+ end
50
+
51
+ # Construct DraftableProxy from a draft
52
+ # Note that @draftable may be nil (if this is a CREATE draft)
53
+ @draft = object
54
+ @draftable = (object.draftable.present? && object.draftable.persisted?) ? object.draftable : nil
55
+ @draftable_class = Object.const_get(object.draftable_type)
56
+ @draft_transaction = object.draft_transaction
57
+ else
58
+ if transaction.blank?
59
+ raise(ArgumentError, "draft_transaction is required when object is a draftable")
60
+ end
61
+
62
+ # Construct DraftableProxy from a draftable
63
+ # Note that @draft may be nil (if the draftable has no changes within the scope of this transaction)
64
+ @draft = transaction.drafts.find_by(draftable: object)
65
+ @draftable = object
66
+ @draftable_class = object.class
67
+ @draft_transaction = transaction
68
+ end
69
+ end
70
+
71
+ # @return [Boolean] +true+ if this +Draft+ is to create a new record,
72
+ # +false+ otherwise
73
+ def create?
74
+ @draft.present? && @draft.create?
75
+ end
76
+
77
+ # @return [Boolean] +true+ if this +Draft+ is to delete an existing
78
+ # record, +false+ otherwise
79
+ def delete?
80
+ @draft.present? && @draft.delete?
81
+ end
82
+
83
+ # Whether or not the proxied +Draft+ or draftable object has any
84
+ # changes.
85
+ #
86
+ # Note, this method only considers changes to attributes and changes
87
+ # to any +belongs_to+ references. Any added / changed / deleted
88
+ # +has_many+ or +has_one+ associations are not considered.
89
+ #
90
+ # @return [Boolean] whether or not the proxied object has changes
91
+ def changed?
92
+ if @draft.blank?
93
+ false # No draft for this object, so nothing changed
94
+ else
95
+ @draft.draft_changes.present?
96
+ end
97
+ end
98
+
99
+ # List of attributes on the proxied +Draft+ or draftable object which
100
+ # have changes.
101
+ #
102
+ # Note, this method only considers changes to attributes and changes
103
+ # to any +belongs_to+ references. Any added / changed / deleted
104
+ # +has_many+ or +has_one+ associations are not considered.
105
+ #
106
+ # @return [Array<String>] array of the attributes which have changed on
107
+ # the proxied object
108
+ def changed
109
+ if @draft.blank?
110
+ [] # No draft for this object, so no attributes have changed
111
+ else
112
+ @draft.draft_changes.keys
113
+ end
114
+ end
115
+
116
+ # Hash of changes on the proxied +Draft+ or draftable object which
117
+ # have changes.
118
+ #
119
+ # Note, this method only considers changes to attributes and changes
120
+ # to any +belongs_to+ references. Any added / changed / deleted
121
+ # +has_many+ or +has_one+ associations are not considered.
122
+ #
123
+ # @return [Hash<String, Array>] hash of the changes on the proxied
124
+ # object, eg. <tt>{ "name" => ["old_name", "new_name"] }</tt>
125
+ def changes
126
+ @changes_memo ||= begin # Memoize result
127
+ if @draft.blank?
128
+ {} # No draft for this object, so no attributes have changed
129
+ else
130
+ @draft.draft_changes.each_with_object({}) do |(k,v), new_hash|
131
+ new_hash[k] = [current_value(k), new_value(k)]
132
+ end
133
+ end
134
+ end
135
+ end
136
+
137
+ # The currently persisted value for the given attribute on the proxied
138
+ # +Draft+ or draftable object.
139
+ #
140
+ # @param attribute_name [String]
141
+ #
142
+ # @return [Object, nil] the old value of the given attribute, or +nil+
143
+ # if there was no previous value
144
+ def current_value(attribute_name)
145
+ # Create hash with default block for auto-memoization
146
+ @current_values_memo ||= Hash.new do |hash, attribute|
147
+ hash[attribute] = begin
148
+ if @draftable.present?
149
+ # Current value is what's on the draftable object
150
+ draft_proxy_for(@draftable.public_send(attribute))
151
+ else
152
+ # No draftable exists, so this must be a CREATE draft, meaning
153
+ # there's no 'old' value...
154
+ association = @draftable_class.reflect_on_association(attribute)
155
+ if (association.blank? || association.belongs_to? || association.has_one?)
156
+ nil # Not an association, or links to a single object
157
+ else
158
+ [] # Is a has_many association
159
+ end
160
+ end
161
+ end
162
+ end
163
+
164
+ # Get memoized value, or calculate and store it
165
+ @current_values_memo[attribute_name.to_s]
166
+ end
167
+
168
+ # The new, drafted value for the given attribute on the proxied +Draft+
169
+ # or draftable object. If no changes have been drafted for the given
170
+ # attribute, then returns the currently persisted value for the
171
+ # attribute.
172
+ #
173
+ # @param attribute_name [String]
174
+ #
175
+ # @return [Object, nil] the new value of the given attribute, or the
176
+ # currently persisted value if there are no draft changes for the
177
+ # attribute
178
+ def new_value(attribute_name)
179
+ raise "#new_value has not been implemented in #{self.class.name}"
180
+ end
181
+
182
+ # Whether any changes will occur to the given association of the proxied
183
+ # +Draft+ or draftable object.
184
+ #
185
+ # @param association_name [String]
186
+ #
187
+ # @return [Boolean] +true+ if any objects will be added to this
188
+ # association, removed from this association, or existing associations
189
+ # changed in any way. +false+ otherwise.
190
+ def association_changed?(association_name)
191
+ raise "#association_changed? has not been implemented in #{self.class.name}"
192
+ end
193
+
194
+ # All associated objects which will be added to the given association of
195
+ # the proxied +Draft+ or draftable object.
196
+ #
197
+ # @param association_name [String]
198
+ #
199
+ # @return [Array<DraftChangesProxy>] DraftChangesProxy objects for each
200
+ # object which will be added to the given association
201
+ def associations_added(association_name)
202
+ raise "#associations_added has not been implemented in #{self.class.name}"
203
+ end
204
+
205
+ # All associated objects which have been updated, but remain
206
+ # the proxied +Draft+ or draftable object.
207
+ #
208
+ # @param association_name [String]
209
+ #
210
+ # @return [Array<DraftChangesProxy>] DraftChangesProxy objects for each
211
+ # object which will be added to the given association
212
+ def associations_updated(association_name)
213
+ raise "#associations_updated has not been implemented in #{self.class.name}"
214
+ end
215
+
216
+ # All associated objects which will be removed from the given
217
+ # association of the proxied +Draft+ or draftable object.
218
+ #
219
+ # @param association_name [String]
220
+ #
221
+ # @return [Array<DraftChangesProxy>] DraftChangesProxy objects for each
222
+ # object which will be removed from the given association
223
+ def associations_removed(association_name)
224
+ raise "#associations_removed has not been implemented in #{self.class.name}"
225
+ end
226
+
227
+ # Returns a string representing the current value of the proxied object.
228
+ #
229
+ # @return [String] the +to_s+ of the current value of the proxied object
230
+ # (ie. the value before any changes would take effect). If there is no
231
+ # current value (ie. this is a proxy for a new draft) then simply
232
+ # returns "New <classname>".
233
+ def current_to_s
234
+ if @draftable.present?
235
+ return "#{@draftable.to_s} <#{@draftable_class} ##{@draftable.id}>"
236
+ else
237
+ # No current draftable
238
+ return "New #{@draftable_class}"
239
+ end
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,33 @@
1
+ require 'draft_approve/draftable/class_methods'
2
+ require 'draft_approve/draftable/instance_methods'
3
+ require 'draft_approve/models/draft'
4
+
5
+ module DraftApprove
6
+ module Draftable
7
+
8
+ # Methods automatically added to +ActiveRecord::Base+ when including the
9
+ # DraftApprove gem
10
+ module BaseClassMethods
11
+
12
+ # Allows the object to be used as a draftable, adding the
13
+ # +DraftApprove::Draftable+ instance and class methods to the object.
14
+ #
15
+ # @param options [Hash] optional configuration, currently unused
16
+ #
17
+ # @example
18
+ # class Person < ActiveRecord::Base
19
+ # acts_as_draftable
20
+ # end
21
+ #
22
+ # @see DraftApprove::Draftable::InstanceMethods
23
+ # @see DraftApprove::Draftable::ClassMethods
24
+ def acts_as_draftable(options={})
25
+ include DraftApprove::Draftable::InstanceMethods
26
+ extend DraftApprove::Draftable::ClassMethods
27
+
28
+ has_many :drafts, as: :draftable
29
+ has_one :draft_pending_approval, -> { pending_approval }, class_name: "Draft", as: :draftable, inverse_of: :draftable
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,119 @@
1
+ require 'draft_approve/transaction'
2
+
3
+ module DraftApprove
4
+ module Draftable
5
+
6
+ # Class methods automatically added to an ActiveRecord model when
7
+ # +acts_as_draftable+ is called
8
+ module ClassMethods
9
+ ##### Basic DraftApprove class methods #####
10
+
11
+ # Starts a new +DraftTransaction+ to group together a number of draft
12
+ # changes that must be approved and applied together.
13
+ #
14
+ # @yield the block which creates a group of draft changes that must be
15
+ # approved and applied together
16
+ # @param created_by [String] the person or process responsible for
17
+ # creating the draft changes in this transaction
18
+ # @param extra_data [Hash] any extra metadata to be associated with
19
+ # these draft changes
20
+ #
21
+ # @return [DraftTransaction, nil] the +DraftTransaction+ which was
22
+ # created, or +nil+ if no draft changes were saved within the given
23
+ # block (ie. if approving the +DraftTransaction+ would be a
24
+ # 'no-operation')
25
+ def draft_transaction(created_by: nil, extra_data: nil)
26
+ DraftApprove::Transaction.in_new_draft_transaction(created_by: created_by, extra_data: extra_data) do
27
+ yield
28
+ end
29
+ end
30
+
31
+ ##### Additional convenience DraftApprove class methods #####
32
+
33
+ # Creates a new object with the given attributes, and saves the new object
34
+ # as a draft.
35
+ #
36
+ # @param attributes [Hash] a hash of attribute names to attribute values,
37
+ # like the hash expected by the ActiveRecord +create+ / +create!+
38
+ # methods
39
+ #
40
+ # @return [Draft] the resulting +Draft+ record (*not* the created
41
+ # draftable object)
42
+ def draft_create!(attributes)
43
+ self.new(attributes).draft_save!
44
+ end
45
+
46
+ # Finds an object with the given attributes. If none found, creates a new
47
+ # object with the given attributes, executes the given block, and saves
48
+ # the new object as a draft.
49
+ #
50
+ # @param attributes [Hash] a hash of attribute names to attribute values,
51
+ # like the hash expected by the ActiveRecord +find_or_create_by+ method
52
+ # @yield [instance] a block which sets additional attributes on the newly
53
+ # created object instance if no existing instance is found
54
+ #
55
+ # @return [Object] the draftable object which was found or created
56
+ # (*not* the +Draft+ object which may have been saved)
57
+ #
58
+ # @example
59
+ # # Find a person by their name. If no person found, create a person
60
+ # # with that name, and also set their birth date.
61
+ # Person.find_or_create_draft_by!(name: 'My Name') do |p|
62
+ # p.birth_date = '1980-01-01'
63
+ # end
64
+ def find_or_create_draft_by!(attributes)
65
+ instance = self.find_by(attributes)
66
+
67
+ if instance.blank?
68
+ instance = self.new(attributes)
69
+
70
+ # Only execute the block if this is a new record
71
+ yield(instance) if block_given?
72
+ end
73
+
74
+ instance.draft_save!
75
+ return instance
76
+ end
77
+
78
+ # Finds an object with the given attributes and draft update it with the
79
+ # given block, or draft create a new object.
80
+ #
81
+ # If an object is found matching the given attributes, the given block
82
+ # is applied to this object and the updates are saved as a draft.
83
+ #
84
+ # If no object is found matching the given attributes, a new object is
85
+ # initialised with the given attributes, and the given block is applied to
86
+ # this new object before it is saved as a draft.
87
+ #
88
+ # @param attributes [Hash] a hash of attribute names to attribute values,
89
+ # like the hash expected by the ActiveRecord +find_or_create_by+ method
90
+ # @yield [instance] a block which makes changes to the object instance
91
+ # which was found or created using the given attributes hash
92
+ #
93
+ # @return [Object] the draftable object which was found and updated, or
94
+ # the draftable object which was created (*not* the +Draft+ object
95
+ # which may have been saved)
96
+ #
97
+ # @example
98
+ # # Find a person by their name, and draft update their birth date,
99
+ # # OR draft create a person with the given name and birth date.
100
+ # Person.find_and_draft_update_or_create_draft_by!(name: 'My Name') do |p|
101
+ # p.birth_date = '1980-01-01'
102
+ # end
103
+ def find_and_draft_update_or_create_draft_by!(attributes)
104
+ instance = self.find_by(attributes)
105
+
106
+ if instance.blank?
107
+ instance = self.new(attributes)
108
+ end
109
+
110
+ # Whether or not this is a new record, execute the block to update
111
+ # additional, non-find_by attributes
112
+ yield(instance) if block_given?
113
+
114
+ instance.draft_save!
115
+ return instance
116
+ end
117
+ end
118
+ end
119
+ end