draft_approve 0.1.0

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