draftsman 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/.gitignore +10 -0
- data/.rspec +3 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +5 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +97 -0
- data/LICENSE +20 -0
- data/README.md +506 -0
- data/Rakefile +6 -0
- data/draftsman.gemspec +33 -0
- data/lib/draftsman/config.rb +13 -0
- data/lib/draftsman/draft.rb +289 -0
- data/lib/draftsman/frameworks/cucumber.rb +7 -0
- data/lib/draftsman/frameworks/rails.rb +58 -0
- data/lib/draftsman/frameworks/rspec.rb +16 -0
- data/lib/draftsman/frameworks/sinatra.rb +31 -0
- data/lib/draftsman/model.rb +428 -0
- data/lib/draftsman/serializers/json.rb +17 -0
- data/lib/draftsman/serializers/yaml.rb +17 -0
- data/lib/draftsman/version.rb +3 -0
- data/lib/draftsman.rb +101 -0
- data/lib/generators/draftsman/install_generator.rb +27 -0
- data/lib/generators/draftsman/templates/add_object_changes_column_to_drafts.rb +9 -0
- data/lib/generators/draftsman/templates/config/initializers/draftsman.rb +11 -0
- data/lib/generators/draftsman/templates/create_drafts.rb +22 -0
- data/spec/controllers/informants_controller_spec.rb +27 -0
- data/spec/controllers/users_controller_spec.rb +23 -0
- data/spec/controllers/whodunnits_controller_spec.rb +24 -0
- data/spec/draftsman_spec.rb +19 -0
- data/spec/dummy/Rakefile +7 -0
- data/spec/dummy/app/assets/images/rails.png +0 -0
- data/spec/dummy/app/assets/javascripts/application.js +15 -0
- data/spec/dummy/app/assets/stylesheets/application.css +13 -0
- data/spec/dummy/app/controllers/application_controller.rb +20 -0
- data/spec/dummy/app/controllers/informants_controller.rb +8 -0
- data/spec/dummy/app/controllers/users_controller.rb +8 -0
- data/spec/dummy/app/controllers/whodunnits_controller.rb +8 -0
- data/spec/dummy/app/helpers/application_helper.rb +2 -0
- data/spec/dummy/app/helpers/messages_helper.rb +2 -0
- data/spec/dummy/app/mailers/.gitkeep +0 -0
- data/spec/dummy/app/models/bastard.rb +3 -0
- data/spec/dummy/app/models/child.rb +4 -0
- data/spec/dummy/app/models/parent.rb +5 -0
- data/spec/dummy/app/models/trashable.rb +3 -0
- data/spec/dummy/app/models/vanilla.rb +3 -0
- data/spec/dummy/app/models/whitelister.rb +3 -0
- data/spec/dummy/app/views/layouts/application.html.erb +15 -0
- data/spec/dummy/config/application.rb +37 -0
- data/spec/dummy/config/boot.rb +6 -0
- data/spec/dummy/config/database.yml +25 -0
- data/spec/dummy/config/environment.rb +5 -0
- data/spec/dummy/config/environments/development.rb +32 -0
- data/spec/dummy/config/environments/production.rb +73 -0
- data/spec/dummy/config/environments/test.rb +39 -0
- data/spec/dummy/config/initializers/backtrace_silencers.rb +7 -0
- data/spec/dummy/config/initializers/inflections.rb +15 -0
- data/spec/dummy/config/initializers/mime_types.rb +5 -0
- data/spec/dummy/config/initializers/secret_token.rb +7 -0
- data/spec/dummy/config/initializers/session_store.rb +8 -0
- data/spec/dummy/config/initializers/wrap_parameters.rb +14 -0
- data/spec/dummy/config/locales/en.yml +5 -0
- data/spec/dummy/config/routes.rb +6 -0
- data/spec/dummy/config.ru +4 -0
- data/spec/dummy/db/migrate/20110208155312_set_up_test_tables.rb +86 -0
- data/spec/dummy/db/schema.rb +106 -0
- data/spec/dummy/db/seeds.rb +7 -0
- data/spec/dummy/lib/assets/.gitkeep +0 -0
- data/spec/dummy/lib/tasks/.gitkeep +0 -0
- data/spec/dummy/log/.gitkeep +0 -0
- data/spec/dummy/public/404.html +26 -0
- data/spec/dummy/public/422.html +26 -0
- data/spec/dummy/public/500.html +25 -0
- data/spec/dummy/public/favicon.ico +0 -0
- data/spec/dummy/script/rails +6 -0
- data/spec/models/child_spec.rb +205 -0
- data/spec/models/draft_spec.rb +297 -0
- data/spec/models/parent_spec.rb +191 -0
- data/spec/models/trashable_spec.rb +164 -0
- data/spec/models/vanilla_spec.rb +201 -0
- data/spec/models/whitelister_spec.rb +262 -0
- data/spec/spec_helper.rb +52 -0
- metadata +304 -0
@@ -0,0 +1,428 @@
|
|
1
|
+
module Draftsman
|
2
|
+
module Model
|
3
|
+
|
4
|
+
def self.included(base)
|
5
|
+
base.send :extend, ClassMethods
|
6
|
+
end
|
7
|
+
|
8
|
+
module ClassMethods
|
9
|
+
# Declare this in your model to enable the Draftsman API for it. A draft of the model is available in the `draft`
|
10
|
+
# association (if one exists).
|
11
|
+
#
|
12
|
+
# Options:
|
13
|
+
#
|
14
|
+
# :class_name
|
15
|
+
# The name of a custom `Draft` class. This class should inherit from `Draftsman::Draft`. A global default can be
|
16
|
+
# set for this using `Draftsman.draft_class_name=` if the default of `Draftsman::Draft` needs to be overridden.
|
17
|
+
#
|
18
|
+
# :ignore
|
19
|
+
# An array of attributes for which an update to a `Draft` will not be stored if they are the only ones changed.
|
20
|
+
#
|
21
|
+
# :only
|
22
|
+
# Inverse of `ignore` - a new `Draft` will be created only for these attributes if supplied. It's recommended that
|
23
|
+
# you only specify optional attributes for this (that can be empty).
|
24
|
+
#
|
25
|
+
# :skip
|
26
|
+
# Fields to ignore completely. As with `ignore`, updates to these fields will not create a new `Draft`. In
|
27
|
+
# addition, these fields will not be included in the serialized versions of the object whenever a new `Draft` is
|
28
|
+
# created.
|
29
|
+
#
|
30
|
+
# :meta
|
31
|
+
# A hash of extra data to store. You must add a column to the `drafts` table for each key. Values are objects or
|
32
|
+
# `procs` (which are called with `self`, i.e. the model with the `has_drafts`). See
|
33
|
+
# `Draftsman::Controller.info_for_draftsman` for an example of how to store data from the controller.
|
34
|
+
#
|
35
|
+
# :draft
|
36
|
+
# The name to use for the `draft` association shortcut method. Default is `:draft`.
|
37
|
+
#
|
38
|
+
# :published_at
|
39
|
+
# The name to use for the method which returns the published timestamp. Default is `published_at`.
|
40
|
+
#
|
41
|
+
# :trashed_at
|
42
|
+
# The name to use for the method which returns the soft delete timestamp. Default is `trashed_at`.
|
43
|
+
def has_drafts(options = {})
|
44
|
+
# Lazily include the instance methods so we don't clutter up
|
45
|
+
# any more ActiveRecord models than we need to.
|
46
|
+
send :include, InstanceMethods
|
47
|
+
|
48
|
+
class_attribute :draftsman_options
|
49
|
+
self.draftsman_options = options.dup
|
50
|
+
|
51
|
+
class_attribute :draft_association_name
|
52
|
+
self.draft_association_name = options[:draft] || :draft
|
53
|
+
|
54
|
+
class_attribute :draft_class_name
|
55
|
+
self.draft_class_name = options[:class_name] || Draftsman.draft_class_name
|
56
|
+
|
57
|
+
[:ignore, :skip, :only].each do |key|
|
58
|
+
draftsman_options[key] = ([draftsman_options[key]].flatten.compact || []).map(&:to_s)
|
59
|
+
end
|
60
|
+
|
61
|
+
draftsman_options[:ignore] << "#{self.draft_association_name}_id"
|
62
|
+
|
63
|
+
draftsman_options[:meta] ||= {}
|
64
|
+
|
65
|
+
attr_accessor :draftsman_event
|
66
|
+
|
67
|
+
class_attribute :published_at_attribute_name
|
68
|
+
self.published_at_attribute_name = options[:published_at] || :published_at
|
69
|
+
|
70
|
+
class_attribute :trashed_at_attribute_name
|
71
|
+
self.trashed_at_attribute_name = options[:trashed_at] || :trashed_at
|
72
|
+
|
73
|
+
# `belongs_to :draft` association
|
74
|
+
belongs_to self.draft_association_name, :class_name => self.draft_class_name, :dependent => :destroy
|
75
|
+
|
76
|
+
# Scopes
|
77
|
+
scope :drafted, lambda { where("#{self.draft_association_name}_id IS NOT NULL") }
|
78
|
+
scope :published, lambda { where("#{self.published_at_attribute_name} IS NOT NULL") }
|
79
|
+
scope :trashed, lambda { where("#{self.trashed_at_attribute_name} IS NOT NULL") }
|
80
|
+
scope :live, lambda { where("#{self.trashed_at_attribute_name} IS NULL") }
|
81
|
+
end
|
82
|
+
|
83
|
+
# Returns whether or not `has_drafts` has been called on this model.
|
84
|
+
def draftable?
|
85
|
+
method_defined?(:draftsman_options)
|
86
|
+
end
|
87
|
+
|
88
|
+
# Serializes attribute changes for `Draft#object_changes` attribute.
|
89
|
+
def serialize_draft_attribute_changes(changes)
|
90
|
+
serialized_attributes.each do |key, coder|
|
91
|
+
if changes.key?(key)
|
92
|
+
coder = Draftsman::Serializers::Yaml unless coder.respond_to?(:dump) # Fall back to YAML if `coder` has no `dump` method
|
93
|
+
old_value, new_value = changes[key]
|
94
|
+
changes[key] = [coder.dump(old_value), coder.dump(new_value)]
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
# Used for `Draft#object` attribute
|
100
|
+
def serialize_attributes_for_draftsman(attributes)
|
101
|
+
serialized_attributes.each do |key, coder|
|
102
|
+
if attributes.key?(key)
|
103
|
+
coder = Draftsman::Serializers::Yaml unless coder.respond_to?(:dump) # Fall back to YAML if `coder` has no `dump` method
|
104
|
+
attributes[key] = coder.dump(attributes[key])
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
# Returns whether or not a `trashed_at` timestamp is set up on this model.
|
110
|
+
def trashable?
|
111
|
+
draftable? && method_defined?(self.trashed_at_attribute_name)
|
112
|
+
end
|
113
|
+
|
114
|
+
# Unserializes attribute changes for `Draft#object_changes` attribute.
|
115
|
+
def unserialize_draft_attribute_changes(changes)
|
116
|
+
serialized_attributes.each do |key, coder|
|
117
|
+
if changes.key?(key)
|
118
|
+
coder = Draftsman::Serializers::Yaml unless coder.respond_to?(:dump)
|
119
|
+
old_value, new_value = changes[key]
|
120
|
+
changes[key] = [coder.load(old_value), coder.load(new_value)]
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
# Used for `Draft#object` attribute
|
126
|
+
def unserialize_attributes_for_draftsman(attributes)
|
127
|
+
serialized_attributes.each do |key, coder|
|
128
|
+
if attributes.key?(key)
|
129
|
+
coder = Draftsman::Serializers::Yaml unless coder.respond_to?(:dump)
|
130
|
+
attributes[key] = coder.load(attributes[key])
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
module InstanceMethods
|
137
|
+
# Returns whether or not this item has a draft.
|
138
|
+
def draft?
|
139
|
+
send(self.class.draft_association_name).present?
|
140
|
+
end
|
141
|
+
|
142
|
+
def draft_at(timestamp, reify_options = {})
|
143
|
+
v = send(self.class.versions_association_name).following(timestamp).first
|
144
|
+
v ? v.reify(reify_options) : self
|
145
|
+
end
|
146
|
+
|
147
|
+
# Creates object and records a draft for the object's creation. Returns `true` or `false` depending on whether or not
|
148
|
+
# the objects passed validation and the save was successful.
|
149
|
+
def draft_creation
|
150
|
+
transaction do
|
151
|
+
# We want to save the draft after create
|
152
|
+
return false unless self.save
|
153
|
+
|
154
|
+
data = {
|
155
|
+
:item => self,
|
156
|
+
:event => 'create',
|
157
|
+
:whodunnit => Draftsman.whodunnit,
|
158
|
+
:object => object_to_string_for_draft
|
159
|
+
}
|
160
|
+
data[:object_changes] = Draftsman.serializer.dump(changes_for_draftsman(previous_changes: true)) if track_object_changes_for_draft?
|
161
|
+
data = merge_metadata_for_draft(data)
|
162
|
+
|
163
|
+
send "build_#{self.class.draft_association_name}", data
|
164
|
+
|
165
|
+
if send(self.class.draft_association_name).save
|
166
|
+
write_attribute "#{self.class.draft_association_name}_id", send(self.class.draft_association_name).id
|
167
|
+
self.update_column "#{self.class.draft_association_name}_id", send(self.class.draft_association_name).id
|
168
|
+
return true
|
169
|
+
else
|
170
|
+
raise ActiveRecord::Rollback and return false
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
# Trashes object and records a draft for a `destroy` event.
|
176
|
+
def draft_destroy
|
177
|
+
transaction do
|
178
|
+
data = {
|
179
|
+
:item => self,
|
180
|
+
:event => 'destroy',
|
181
|
+
:whodunnit => Draftsman.whodunnit,
|
182
|
+
:object => object_to_string_for_draft
|
183
|
+
}
|
184
|
+
|
185
|
+
# Stash previous draft in case it needs to be reverted later
|
186
|
+
if self.draft?
|
187
|
+
data[:previous_draft] = Draftsman.serializer.dump(send(self.class.draft_association_name).attributes)
|
188
|
+
end
|
189
|
+
|
190
|
+
data = merge_metadata_for_draft(data)
|
191
|
+
|
192
|
+
if send(self.class.draft_association_name).present?
|
193
|
+
send(self.class.draft_association_name).update_attributes! data
|
194
|
+
else
|
195
|
+
send("build_#{self.class.draft_association_name}", data)
|
196
|
+
send(self.class.draft_association_name).save!
|
197
|
+
send "#{self.class.draft_association_name}_id=", send(self.class.draft_association_name).id
|
198
|
+
self.update_column "#{self.class.draft_association_name}_id", send(self.class.draft_association_name).id
|
199
|
+
end
|
200
|
+
|
201
|
+
trash!
|
202
|
+
|
203
|
+
# Mock `dependent: :destroy` behavior for all trashable associations
|
204
|
+
dependent_associations = self.class.reflect_on_all_associations(:has_one) + self.class.reflect_on_all_associations(:has_many)
|
205
|
+
|
206
|
+
dependent_associations.each do |association|
|
207
|
+
|
208
|
+
if association.klass.draftable? && association.options.has_key?(:dependent) && association.options[:dependent] == :destroy
|
209
|
+
dependents = association.macro == :has_one ? [self.send(association.name)] : self.send(association.name)
|
210
|
+
|
211
|
+
dependents.each do |dependent|
|
212
|
+
dependent.draft_destroy unless dependent.draft? && dependent.send(dependent.class.draft_association_name).destroy?
|
213
|
+
end
|
214
|
+
end
|
215
|
+
end
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
219
|
+
# Updates object and records a draft for an `update` event. If the draft is being updated to the object's original
|
220
|
+
# state, the draft is destroyed. Returns `true` or `false` depending on if the object passed validation and the save
|
221
|
+
# was successful.
|
222
|
+
def draft_update
|
223
|
+
transaction do
|
224
|
+
save_only_columns_for_draft
|
225
|
+
|
226
|
+
# We want to save the draft before update
|
227
|
+
return false unless self.valid?
|
228
|
+
|
229
|
+
# If updating a creation draft, also update this item
|
230
|
+
if self.draft? && send(self.class.draft_association_name).create?
|
231
|
+
data = {
|
232
|
+
:item => self,
|
233
|
+
:whodunnit => Draftsman.whodunnit,
|
234
|
+
:object => object_to_string_for_draft
|
235
|
+
}
|
236
|
+
|
237
|
+
if track_object_changes_for_draft?
|
238
|
+
data[:object_changes] = Draftsman.serializer.dump(changes_for_draftsman(changed_from: self.send(self.class.draft_association_name).changeset))
|
239
|
+
end
|
240
|
+
|
241
|
+
data = merge_metadata_for_draft(data)
|
242
|
+
send(self.class.draft_association_name).update_attributes data
|
243
|
+
|
244
|
+
self.save
|
245
|
+
# Destroy the draft if this record has changed back to the original record
|
246
|
+
elsif changed_to_original_for_draft?
|
247
|
+
send(self.class.draft_association_name).destroy
|
248
|
+
send "#{self.class.draft_association_name}_id=", nil
|
249
|
+
self.update_column "#{self.class.draft_association_name}_id", nil
|
250
|
+
true
|
251
|
+
# Save a draft if record is changed notably
|
252
|
+
elsif changed_notably_for_draft?
|
253
|
+
data = {
|
254
|
+
:item => self,
|
255
|
+
:whodunnit => Draftsman.whodunnit,
|
256
|
+
:object => object_to_string_for_draft
|
257
|
+
}
|
258
|
+
data = merge_metadata_for_draft(data)
|
259
|
+
|
260
|
+
# If there's already a draft, update it
|
261
|
+
if send(self.class.draft_association_name).present?
|
262
|
+
if track_object_changes_for_draft?
|
263
|
+
data[:object_changes] = Draftsman.serializer.dump(changes_for_draftsman)
|
264
|
+
end
|
265
|
+
|
266
|
+
send(self.class.draft_association_name).update_attributes data
|
267
|
+
# If there's not draft, create an update draft
|
268
|
+
else
|
269
|
+
data[:event] = 'update'
|
270
|
+
data[:object_changes] = Draftsman.serializer.dump(changes_for_draftsman) if track_object_changes_for_draft?
|
271
|
+
send "build_#{self.class.draft_association_name}", data
|
272
|
+
|
273
|
+
if send(self.class.draft_association_name).save
|
274
|
+
write_attribute "#{self.class.draft_association_name}_id", send(self.class.draft_association_name).id
|
275
|
+
self.update_column "#{self.class.draft_association_name}_id", send(self.class.draft_association_name).id
|
276
|
+
else
|
277
|
+
raise ActiveRecord::Rollback and return false
|
278
|
+
end
|
279
|
+
end
|
280
|
+
# If record is a draft and not changed notably, then update the draft
|
281
|
+
elsif self.draft?
|
282
|
+
data = {
|
283
|
+
:item => self,
|
284
|
+
:whodunnit => Draftsman.whodunnit,
|
285
|
+
:object => object_to_string_for_draft
|
286
|
+
}
|
287
|
+
data[:object_changes] = Draftsman.serializer.dump(changes_for_draftsman(changed_from: @object.draft.changeset)) if track_object_changes_for_draft?
|
288
|
+
data = merge_metadata_for_draft(data)
|
289
|
+
|
290
|
+
send(self.class.draft_association_name).update_attributes data
|
291
|
+
# Otherwise, just save the record
|
292
|
+
else
|
293
|
+
self.save
|
294
|
+
end
|
295
|
+
end
|
296
|
+
rescue Exception => e
|
297
|
+
false
|
298
|
+
end
|
299
|
+
|
300
|
+
# Returns serialized object representing this drafted item.
|
301
|
+
def object_to_string_for_draft(object = nil)
|
302
|
+
object ||= self
|
303
|
+
|
304
|
+
_attrs = object.attributes.except(*self.class.draftsman_options[:skip]).tap do |attributes|
|
305
|
+
self.class.serialize_attributes_for_draftsman attributes
|
306
|
+
end
|
307
|
+
|
308
|
+
Draftsman.serializer.dump(_attrs)
|
309
|
+
end
|
310
|
+
|
311
|
+
# Returns whether or not this item has been published at any point in its lifecycle.
|
312
|
+
def published?
|
313
|
+
self.published_at.present?
|
314
|
+
end
|
315
|
+
|
316
|
+
# Returns whether or not this item has been trashed
|
317
|
+
def trashed?
|
318
|
+
send(self.class.trashed_at_attribute_name).present?
|
319
|
+
end
|
320
|
+
|
321
|
+
private
|
322
|
+
|
323
|
+
# Returns changes on this object, excluding attributes defined in the options for `:ignore` and `:skip`.
|
324
|
+
def changed_and_not_ignored_for_draft(options = {})
|
325
|
+
options[:previous_changes] ||= false
|
326
|
+
|
327
|
+
my_changed = options[:previous_changes] ? previous_changes.keys : self.changed
|
328
|
+
|
329
|
+
ignore = self.class.draftsman_options[:ignore]
|
330
|
+
skip = self.class.draftsman_options[:skip]
|
331
|
+
my_changed - ignore - skip
|
332
|
+
end
|
333
|
+
|
334
|
+
# Returns whether or not this instance has changes that should trigger a new draft.
|
335
|
+
def changed_notably_for_draft?
|
336
|
+
notably_changed_attributes_for_draft.any?
|
337
|
+
end
|
338
|
+
|
339
|
+
# Returns whether or not the updates change this draft back to the original state
|
340
|
+
def changed_to_original_for_draft?
|
341
|
+
send(self.draft_association_name).present? && send(self.class.draft_association_name).update? && !changed_notably_for_draft?
|
342
|
+
end
|
343
|
+
|
344
|
+
# Returns array of attributes that have changed for the object.
|
345
|
+
def changes_for_draftsman(options = {})
|
346
|
+
options[:changed_from] ||= {}
|
347
|
+
options[:previous_changes] ||= false
|
348
|
+
|
349
|
+
my_changes = options[:previous_changes] ? self.previous_changes : self.changes
|
350
|
+
|
351
|
+
new_changes = my_changes.delete_if do |key, value|
|
352
|
+
!notably_changed_attributes_for_draft(previous_changes: options[:previous_changes]).include?(key)
|
353
|
+
end.tap do |changes|
|
354
|
+
self.class.serialize_draft_attribute_changes(changes) # Use serialized value for attributes when necessary
|
355
|
+
end
|
356
|
+
|
357
|
+
new_changes.each do |attribute, value|
|
358
|
+
new_changes[attribute][0] = options[:changed_from][attribute][0] if options[:changed_from].has_key?(attribute)
|
359
|
+
end
|
360
|
+
|
361
|
+
# We need to merge any previous changes so they are not lost on further updates before committing or
|
362
|
+
# reverting
|
363
|
+
options[:changed_from].merge new_changes
|
364
|
+
end
|
365
|
+
|
366
|
+
# Returns draft class.
|
367
|
+
def draft_class
|
368
|
+
self.draft_class_name.constantize
|
369
|
+
end
|
370
|
+
|
371
|
+
# Merges model-level metadata from `meta` and `controller_info` into draft object.
|
372
|
+
def merge_metadata_for_draft(data)
|
373
|
+
# First, we merge the model-level metadata in `meta`.
|
374
|
+
draftsman_options[:meta].each do |attribute, value|
|
375
|
+
data[attribute] =
|
376
|
+
if value.respond_to?(:call)
|
377
|
+
value.call(self)
|
378
|
+
elsif value.is_a?(Symbol) && respond_to?(value)
|
379
|
+
# if it is an attribute that is changing, be sure to grab the current version
|
380
|
+
if has_attribute?(value) && send("#{value}_changed?".to_sym)
|
381
|
+
send("#{value}_was".to_sym)
|
382
|
+
else
|
383
|
+
send(value)
|
384
|
+
end
|
385
|
+
else
|
386
|
+
value
|
387
|
+
end
|
388
|
+
end
|
389
|
+
|
390
|
+
# Second, we merge any extra data from the controller (if available).
|
391
|
+
data.merge(Draftsman.controller_info || {})
|
392
|
+
end
|
393
|
+
|
394
|
+
# Returns array of attributes that were changed to trigger a draft.
|
395
|
+
def notably_changed_attributes_for_draft(options = {})
|
396
|
+
options[:previous_changes] ||= false
|
397
|
+
|
398
|
+
only = self.class.draftsman_options[:only]
|
399
|
+
only.empty? ? changed_and_not_ignored_for_draft(previous_changes: options[:previous_changes]) : (changed_and_not_ignored_for_draft(previous_changes: options[:previous_changes]) & only)
|
400
|
+
end
|
401
|
+
|
402
|
+
# Save columns outside of the `only` option directly to master table
|
403
|
+
def save_only_columns_for_draft
|
404
|
+
if self.class.draftsman_options[:only].any?
|
405
|
+
only_changes = {}
|
406
|
+
only_changed_attributes = self.changed - self.class.draftsman_options[:only]
|
407
|
+
|
408
|
+
only_changed_attributes.each do |attribute|
|
409
|
+
only_changes[attribute] = self.changes[attribute].last
|
410
|
+
end
|
411
|
+
|
412
|
+
self.update_columns only_changes if only_changes.any?
|
413
|
+
end
|
414
|
+
end
|
415
|
+
|
416
|
+
# Returns whether or not the draft class includes an `object_changes` attribute.
|
417
|
+
def track_object_changes_for_draft?
|
418
|
+
draft_class.column_names.include? 'object_changes'
|
419
|
+
end
|
420
|
+
|
421
|
+
# Sets `trashed_at` attribute to now and saves to the database immediately.
|
422
|
+
def trash!
|
423
|
+
write_attribute self.class.trashed_at_attribute_name, Time.now
|
424
|
+
self.update_column self.class.trashed_at_attribute_name, send(self.class.trashed_at_attribute_name)
|
425
|
+
end
|
426
|
+
end
|
427
|
+
end
|
428
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
require 'active_support/json'
|
2
|
+
|
3
|
+
module Draftsman
|
4
|
+
module Serializers
|
5
|
+
module Json
|
6
|
+
extend self # makes all instance methods become module methods as well
|
7
|
+
|
8
|
+
def load(string)
|
9
|
+
ActiveSupport::JSON.decode string
|
10
|
+
end
|
11
|
+
|
12
|
+
def dump(object)
|
13
|
+
ActiveSupport::JSON.encode object
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
|
3
|
+
module Draftsman
|
4
|
+
module Serializers
|
5
|
+
module Yaml
|
6
|
+
extend self # makes all instance methods become module methods as well
|
7
|
+
|
8
|
+
def load(string)
|
9
|
+
YAML.load string
|
10
|
+
end
|
11
|
+
|
12
|
+
def dump(object)
|
13
|
+
YAML.dump object
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
data/lib/draftsman.rb
ADDED
@@ -0,0 +1,101 @@
|
|
1
|
+
require 'draftsman/config'
|
2
|
+
require 'draftsman/model'
|
3
|
+
|
4
|
+
# Require all frameworks and serializers
|
5
|
+
Dir[File.join(File.dirname(__FILE__), 'draftsman', 'frameworks', '*.rb')].each { |file| require file }
|
6
|
+
Dir[File.join(File.dirname(__FILE__), 'draftsman', 'serializers', '*.rb')].each { |file| require file }
|
7
|
+
|
8
|
+
# Draftsman's module methods can be called in both models and controllers.
|
9
|
+
module Draftsman
|
10
|
+
# Returns whether or not ActiveRecord is configured to require mass assignment whitelisting via `attr_accessible`.
|
11
|
+
def self.active_record_protected_attributes?
|
12
|
+
@active_record_protected_attributes ||= ActiveRecord::VERSION::STRING.to_f < 4.0 || defined?(ProtectedAttributes)
|
13
|
+
end
|
14
|
+
|
15
|
+
# Returns any information from the controller that you want Draftsman to store.
|
16
|
+
#
|
17
|
+
# See `Draftsman::Controller#info_for_draftsman`.
|
18
|
+
def self.controller_info
|
19
|
+
draftsman_store[:controller_info]
|
20
|
+
end
|
21
|
+
|
22
|
+
# Sets any information from the controller that you want Draftsman to store. By default, this is set automatically by
|
23
|
+
# a before filter.
|
24
|
+
def self.controller_info=(value)
|
25
|
+
draftsman_store[:controller_info] = value
|
26
|
+
end
|
27
|
+
|
28
|
+
# Returns default class name used for drafts.
|
29
|
+
def self.draft_class_name
|
30
|
+
draftsman_store[:draft_class_name]
|
31
|
+
end
|
32
|
+
|
33
|
+
# Sets default class name to use for drafts.
|
34
|
+
def self.draft_class_name=(class_name)
|
35
|
+
draftsman_store[:draft_class_name] = class_name
|
36
|
+
end
|
37
|
+
|
38
|
+
# Set the field which records when a draft was created.
|
39
|
+
def self.timestamp_field=(field_name)
|
40
|
+
Draftsman.config.timestamp_field = field_name
|
41
|
+
end
|
42
|
+
|
43
|
+
# Returns the field which records when a draft was created.
|
44
|
+
def self.timestamp_field
|
45
|
+
Draftsman.config.timestamp_field
|
46
|
+
end
|
47
|
+
|
48
|
+
# Returns serializer to use for `object`, `object_changes`, and `previous_draft` columns.
|
49
|
+
def self.serializer
|
50
|
+
Draftsman.config.serializer
|
51
|
+
end
|
52
|
+
|
53
|
+
# Sets serializer to use for `object`, `object_changes`, and `previous_draft` columns.
|
54
|
+
def self.serializer=(value)
|
55
|
+
Draftsman.config.serializer = value
|
56
|
+
end
|
57
|
+
|
58
|
+
# Returns who is reponsible for any changes that occur.
|
59
|
+
def self.whodunnit
|
60
|
+
draftsman_store[:whodunnit]
|
61
|
+
end
|
62
|
+
|
63
|
+
# Sets who is responsible for any changes that occur.
|
64
|
+
# You would normally use this in a migration or on the console,
|
65
|
+
# when working with models directly. In a controller, it is set
|
66
|
+
# automatically to the `current_user`.
|
67
|
+
def self.whodunnit=(value)
|
68
|
+
draftsman_store[:whodunnit] = value
|
69
|
+
end
|
70
|
+
|
71
|
+
private
|
72
|
+
|
73
|
+
# Thread-safe hash to hold Draftman's data. Initializing with needed default values.
|
74
|
+
def self.draftsman_store
|
75
|
+
Thread.current[:draft] ||= { :draft_class_name => 'Draftsman::Draft' }
|
76
|
+
end
|
77
|
+
|
78
|
+
# Returns Draftman's configuration object.
|
79
|
+
def self.config
|
80
|
+
@@config ||= Draftsman::Config.instance
|
81
|
+
end
|
82
|
+
|
83
|
+
def self.configure
|
84
|
+
yield config
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
# Draft model class.
|
89
|
+
require 'draftsman/draft'
|
90
|
+
|
91
|
+
# Inject `Draftsman::Model` into ActiveRecord classes.
|
92
|
+
ActiveSupport.on_load(:active_record) do
|
93
|
+
include Draftsman::Model
|
94
|
+
end
|
95
|
+
|
96
|
+
# Inject `Draftsman::Rails::Controller` into Rails controllers.
|
97
|
+
if defined?(ActionController)
|
98
|
+
ActiveSupport.on_load(:action_controller) do
|
99
|
+
include Draftsman::Rails::Controller
|
100
|
+
end
|
101
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require 'rails/generators'
|
2
|
+
require 'rails/generators/migration'
|
3
|
+
require 'rails/generators/active_record'
|
4
|
+
|
5
|
+
module Draftsman
|
6
|
+
class InstallGenerator < ::Rails::Generators::Base
|
7
|
+
include ::Rails::Generators::Migration
|
8
|
+
|
9
|
+
desc 'Generates (but does not run) a migration to add a drafts table.'
|
10
|
+
source_root File.expand_path('../templates', __FILE__)
|
11
|
+
class_option :skip_initializer, :type => :boolean, :default => false, :desc => 'Skip generation of the boilerplate initializer at `config/initializers/draftsman.rb`.'
|
12
|
+
class_option :with_changes, :type => :boolean, :default => false, :desc => 'Store changeset (diff) with each draft'
|
13
|
+
|
14
|
+
def create_migration_file
|
15
|
+
migration_template 'create_drafts.rb', 'db/migrate/create_drafts.rb'
|
16
|
+
migration_template 'add_object_changes_column_to_drafts.rb', 'db/migrate/add_object_changes_column_to_drafts.rb' if options.with_changes?
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.next_migration_number(dirname)
|
20
|
+
ActiveRecord::Generators::Base.next_migration_number(dirname)
|
21
|
+
end
|
22
|
+
|
23
|
+
def copy_config
|
24
|
+
template 'config/initializers/draftsman.rb' unless options.skip_initializer?
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
# Override global `draft` class. For example, perhaps you want your own class at `app/models/draft.rb` that adds
|
2
|
+
# extra attributes, validations, associations, etc. Be sure that this new model class extends `Draftsman::Draft`.
|
3
|
+
# Draftsman.draft_class_name = 'Draftsman::Draft'
|
4
|
+
|
5
|
+
# Serializer for `object`, `object_changes`, and `previous_draft` columns. To use the JSON serializer, change to
|
6
|
+
# `Draftsman::Serializers::Json`. You could implement your own serializer if you really wanted to. See files in
|
7
|
+
# `lib/draftsman/serializers`.
|
8
|
+
# Draftsman.serializer = Draftsman::Serializers::Json
|
9
|
+
|
10
|
+
# Field which records when a draft was created.
|
11
|
+
# Draftsman.timestamp_field = :created_at
|
@@ -0,0 +1,22 @@
|
|
1
|
+
class CreateDrafts < ActiveRecord::Migration
|
2
|
+
def change
|
3
|
+
create_table :drafts do |t|
|
4
|
+
t.string :item_type, :null => false
|
5
|
+
t.integer :item_id, :null => false
|
6
|
+
t.string :event, :null => false
|
7
|
+
t.string :whodunnit# :null => false
|
8
|
+
t.text :object
|
9
|
+
t.text :previous_draft
|
10
|
+
t.timestamps
|
11
|
+
end
|
12
|
+
|
13
|
+
change_table :drafts do |t|
|
14
|
+
t.index :item_type
|
15
|
+
t.index :item_id
|
16
|
+
t.index :event
|
17
|
+
t.index :whodunnit
|
18
|
+
t.index :created_at
|
19
|
+
t.index :updated_at
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
# Tests controller `info_for_draftsman` method
|
4
|
+
describe InformantsController do
|
5
|
+
let(:trashable) { Trashable.create!(:name => 'Bob') }
|
6
|
+
|
7
|
+
describe :create do
|
8
|
+
before { post :create }
|
9
|
+
subject { Draftsman::Draft.last }
|
10
|
+
its(:ip) { should eql '123.45.67.89' }
|
11
|
+
its(:user_agent) { should eql '007' }
|
12
|
+
end
|
13
|
+
|
14
|
+
describe :update do
|
15
|
+
before { put :update, :id => trashable.id }
|
16
|
+
subject { Draftsman::Draft.last }
|
17
|
+
its(:ip) { should eql '123.45.67.89' }
|
18
|
+
its(:user_agent) { should eql '007' }
|
19
|
+
end
|
20
|
+
|
21
|
+
describe :destroy do
|
22
|
+
before { delete :destroy, :id => trashable.id }
|
23
|
+
subject { Draftsman::Draft.last }
|
24
|
+
its(:ip) { should eql '123.45.67.89' }
|
25
|
+
its(:user_agent) { should eql '007' }
|
26
|
+
end
|
27
|
+
end
|