paper_trail_without_deprecated 3.0.0.beta1
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 +16 -0
- data/.rspec +3 -0
- data/.travis.yml +21 -0
- data/CHANGELOG.md +68 -0
- data/Gemfile +2 -0
- data/MIT-LICENSE +20 -0
- data/README.md +979 -0
- data/Rakefile +18 -0
- data/gemfiles/3.0.gemfile +31 -0
- data/lib/generators/paper_trail/USAGE +2 -0
- data/lib/generators/paper_trail/install_generator.rb +23 -0
- data/lib/generators/paper_trail/templates/add_object_changes_column_to_versions.rb +9 -0
- data/lib/generators/paper_trail/templates/create_versions.rb +18 -0
- data/lib/paper_trail.rb +115 -0
- data/lib/paper_trail/cleaner.rb +34 -0
- data/lib/paper_trail/config.rb +14 -0
- data/lib/paper_trail/frameworks/cucumber.rb +31 -0
- data/lib/paper_trail/frameworks/rails.rb +79 -0
- data/lib/paper_trail/frameworks/rspec.rb +24 -0
- data/lib/paper_trail/frameworks/rspec/extensions.rb +20 -0
- data/lib/paper_trail/frameworks/sinatra.rb +31 -0
- data/lib/paper_trail/has_paper_trail.rb +308 -0
- data/lib/paper_trail/serializers/json.rb +17 -0
- data/lib/paper_trail/serializers/yaml.rb +17 -0
- data/lib/paper_trail/version.rb +200 -0
- data/lib/paper_trail/version_number.rb +3 -0
- data/paper_trail.gemspec +36 -0
- data/spec/models/widget_spec.rb +13 -0
- data/spec/paper_trail_spec.rb +47 -0
- data/spec/spec_helper.rb +41 -0
- data/test/custom_json_serializer.rb +13 -0
- data/test/dummy/Rakefile +7 -0
- data/test/dummy/app/controllers/application_controller.rb +17 -0
- data/test/dummy/app/controllers/test_controller.rb +5 -0
- data/test/dummy/app/controllers/widgets_controller.rb +31 -0
- data/test/dummy/app/helpers/application_helper.rb +2 -0
- data/test/dummy/app/models/animal.rb +4 -0
- data/test/dummy/app/models/article.rb +16 -0
- data/test/dummy/app/models/authorship.rb +5 -0
- data/test/dummy/app/models/book.rb +5 -0
- data/test/dummy/app/models/cat.rb +2 -0
- data/test/dummy/app/models/document.rb +4 -0
- data/test/dummy/app/models/dog.rb +2 -0
- data/test/dummy/app/models/elephant.rb +3 -0
- data/test/dummy/app/models/fluxor.rb +3 -0
- data/test/dummy/app/models/foo_widget.rb +2 -0
- data/test/dummy/app/models/legacy_widget.rb +4 -0
- data/test/dummy/app/models/person.rb +28 -0
- data/test/dummy/app/models/post.rb +4 -0
- data/test/dummy/app/models/protected_widget.rb +3 -0
- data/test/dummy/app/models/song.rb +12 -0
- data/test/dummy/app/models/translation.rb +4 -0
- data/test/dummy/app/models/widget.rb +10 -0
- data/test/dummy/app/models/wotsit.rb +4 -0
- data/test/dummy/app/versions/post_version.rb +3 -0
- data/test/dummy/app/views/layouts/application.html.erb +14 -0
- data/test/dummy/config.ru +4 -0
- data/test/dummy/config/application.rb +63 -0
- data/test/dummy/config/boot.rb +10 -0
- data/test/dummy/config/database.yml +22 -0
- data/test/dummy/config/environment.rb +5 -0
- data/test/dummy/config/environments/development.rb +40 -0
- data/test/dummy/config/environments/production.rb +73 -0
- data/test/dummy/config/environments/test.rb +37 -0
- data/test/dummy/config/initializers/backtrace_silencers.rb +7 -0
- data/test/dummy/config/initializers/inflections.rb +10 -0
- data/test/dummy/config/initializers/mime_types.rb +5 -0
- data/test/dummy/config/initializers/paper_trail.rb +5 -0
- data/test/dummy/config/initializers/secret_token.rb +7 -0
- data/test/dummy/config/initializers/session_store.rb +8 -0
- data/test/dummy/config/locales/en.yml +5 -0
- data/test/dummy/config/routes.rb +3 -0
- data/test/dummy/db/migrate/20110208155312_set_up_test_tables.rb +136 -0
- data/test/dummy/db/schema.rb +101 -0
- data/test/dummy/public/404.html +26 -0
- data/test/dummy/public/422.html +26 -0
- data/test/dummy/public/500.html +26 -0
- data/test/dummy/public/favicon.ico +0 -0
- data/test/dummy/public/javascripts/application.js +2 -0
- data/test/dummy/public/javascripts/controls.js +965 -0
- data/test/dummy/public/javascripts/dragdrop.js +974 -0
- data/test/dummy/public/javascripts/effects.js +1123 -0
- data/test/dummy/public/javascripts/prototype.js +6001 -0
- data/test/dummy/public/javascripts/rails.js +175 -0
- data/test/dummy/public/stylesheets/.gitkeep +0 -0
- data/test/dummy/script/rails +6 -0
- data/test/functional/controller_test.rb +90 -0
- data/test/functional/modular_sinatra_test.rb +44 -0
- data/test/functional/sinatra_test.rb +45 -0
- data/test/functional/thread_safety_test.rb +26 -0
- data/test/paper_trail_test.rb +27 -0
- data/test/test_helper.rb +40 -0
- data/test/unit/cleaner_test.rb +143 -0
- data/test/unit/inheritance_column_test.rb +43 -0
- data/test/unit/model_test.rb +1314 -0
- data/test/unit/protected_attrs_test.rb +46 -0
- data/test/unit/serializer_test.rb +117 -0
- data/test/unit/serializers/json_test.rb +40 -0
- data/test/unit/serializers/mixin_json_test.rb +36 -0
- data/test/unit/serializers/mixin_yaml_test.rb +49 -0
- data/test/unit/serializers/yaml_test.rb +40 -0
- data/test/unit/timestamp_test.rb +44 -0
- data/test/unit/version_test.rb +74 -0
- metadata +286 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
module PaperTrail
|
|
2
|
+
module Model
|
|
3
|
+
|
|
4
|
+
def self.included(base)
|
|
5
|
+
base.send :extend, ClassMethods
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
module ClassMethods
|
|
10
|
+
# Declare this in your model to track every create, update, and destroy. Each version of
|
|
11
|
+
# the model is available in the `versions` association.
|
|
12
|
+
#
|
|
13
|
+
# Options:
|
|
14
|
+
# :on the events to track (optional; defaults to all of them). Set to an array of
|
|
15
|
+
# `:create`, `:update`, `:destroy` as desired.
|
|
16
|
+
# :class_name the name of a custom Version class. This class should inherit from Version.
|
|
17
|
+
# :ignore an array of attributes for which a new `Version` will not be created if only they change.
|
|
18
|
+
# :if, :unless Procs that allow to specify conditions when to save versions for an object
|
|
19
|
+
# :only inverse of `ignore` - a new `Version` will be created only for these attributes if supplied
|
|
20
|
+
# :skip fields to ignore completely. As with `ignore`, updates to these fields will not create
|
|
21
|
+
# a new `Version`. In addition, these fields will not be included in the serialized versions
|
|
22
|
+
# of the object whenever a new `Version` is created.
|
|
23
|
+
# :meta a hash of extra data to store. You must add a column to the `versions` table for each key.
|
|
24
|
+
# Values are objects or procs (which are called with `self`, i.e. the model with the paper
|
|
25
|
+
# trail). See `PaperTrail::Controller.info_for_paper_trail` for how to store data from
|
|
26
|
+
# the controller.
|
|
27
|
+
# :versions the name to use for the versions association. Default is `:versions`.
|
|
28
|
+
# :version the name to use for the method which returns the version the instance was reified from.
|
|
29
|
+
# Default is `:version`.
|
|
30
|
+
def has_paper_trail(options = {})
|
|
31
|
+
# Lazily include the instance methods so we don't clutter up
|
|
32
|
+
# any more ActiveRecord models than we have to.
|
|
33
|
+
send :include, InstanceMethods
|
|
34
|
+
|
|
35
|
+
class_attribute :version_association_name
|
|
36
|
+
self.version_association_name = options[:version] || :version
|
|
37
|
+
|
|
38
|
+
# The version this instance was reified from.
|
|
39
|
+
attr_accessor self.version_association_name
|
|
40
|
+
|
|
41
|
+
class_attribute :version_class_name
|
|
42
|
+
self.version_class_name = options[:class_name] || 'PaperTrail::Version'
|
|
43
|
+
|
|
44
|
+
class_attribute :paper_trail_options
|
|
45
|
+
self.paper_trail_options = options.dup
|
|
46
|
+
|
|
47
|
+
[:ignore, :skip, :only].each do |k|
|
|
48
|
+
paper_trail_options[k] =
|
|
49
|
+
([paper_trail_options[k]].flatten.compact || []).map &:to_s
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
paper_trail_options[:meta] ||= {}
|
|
53
|
+
|
|
54
|
+
class_attribute :paper_trail_enabled_for_model
|
|
55
|
+
self.paper_trail_enabled_for_model = true
|
|
56
|
+
|
|
57
|
+
class_attribute :versions_association_name
|
|
58
|
+
self.versions_association_name = options[:versions] || :versions
|
|
59
|
+
|
|
60
|
+
attr_accessor :paper_trail_event
|
|
61
|
+
|
|
62
|
+
if ActiveRecord::VERSION::STRING.to_f >= 4.0 # `has_many` syntax for specifying order uses a lambda in Rails 4
|
|
63
|
+
has_many self.versions_association_name,
|
|
64
|
+
lambda { order("#{PaperTrail.timestamp_field} ASC") },
|
|
65
|
+
:class_name => self.version_class_name, :as => :item
|
|
66
|
+
else
|
|
67
|
+
has_many self.versions_association_name,
|
|
68
|
+
:class_name => version_class_name,
|
|
69
|
+
:as => :item,
|
|
70
|
+
:order => "#{PaperTrail.timestamp_field} ASC"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
options_on = Array(options[:on]) # so that a single symbol can be passed in without wrapping it in an `Array`
|
|
74
|
+
after_create :record_create, :if => :save_version? if options_on.empty? || options_on.include?(:create)
|
|
75
|
+
before_update :record_update, :if => :save_version? if options_on.empty? || options_on.include?(:update)
|
|
76
|
+
after_destroy :record_destroy, :if => :save_version? if options_on.empty? || options_on.include?(:destroy)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Switches PaperTrail off for this class.
|
|
80
|
+
def paper_trail_off
|
|
81
|
+
self.paper_trail_enabled_for_model = false
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Switches PaperTrail on for this class.
|
|
85
|
+
def paper_trail_on
|
|
86
|
+
self.paper_trail_enabled_for_model = true
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Used for Version#object attribute
|
|
90
|
+
def serialize_attributes_for_paper_trail(attributes)
|
|
91
|
+
serialized_attributes.each do |key, coder|
|
|
92
|
+
if attributes.key?(key)
|
|
93
|
+
coder = PaperTrail::Serializers::Yaml unless coder.respond_to?(:dump) # Fall back to YAML if `coder` has no `dump` method
|
|
94
|
+
attributes[key] = coder.dump(attributes[key])
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def unserialize_attributes_for_paper_trail(attributes)
|
|
100
|
+
serialized_attributes.each do |key, coder|
|
|
101
|
+
if attributes.key?(key)
|
|
102
|
+
coder = PaperTrail::Serializers::Yaml unless coder.respond_to?(:dump)
|
|
103
|
+
attributes[key] = coder.load(attributes[key])
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Used for Version#object_changes attribute
|
|
109
|
+
def serialize_attribute_changes(changes)
|
|
110
|
+
serialized_attributes.each do |key, coder|
|
|
111
|
+
if changes.key?(key)
|
|
112
|
+
coder = PaperTrail::Serializers::Yaml unless coder.respond_to?(:dump) # Fall back to YAML if `coder` has no `dump` method
|
|
113
|
+
old_value, new_value = changes[key]
|
|
114
|
+
changes[key] = [coder.dump(old_value),
|
|
115
|
+
coder.dump(new_value)]
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def unserialize_attribute_changes(changes)
|
|
121
|
+
serialized_attributes.each do |key, coder|
|
|
122
|
+
if changes.key?(key)
|
|
123
|
+
coder = PaperTrail::Serializers::Yaml unless coder.respond_to?(:dump)
|
|
124
|
+
old_value, new_value = changes[key]
|
|
125
|
+
changes[key] = [coder.load(old_value),
|
|
126
|
+
coder.load(new_value)]
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Wrap the following methods in a module so we can include them only in the
|
|
133
|
+
# ActiveRecord models that declare `has_paper_trail`.
|
|
134
|
+
module InstanceMethods
|
|
135
|
+
# Returns true if this instance is the current, live one;
|
|
136
|
+
# returns false if this instance came from a previous version.
|
|
137
|
+
def live?
|
|
138
|
+
source_version.nil?
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Returns who put the object into its current state.
|
|
142
|
+
def originator
|
|
143
|
+
version_class.with_item_keys(self.class.base_class.name, id).last.try :whodunnit
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Returns the object (not a Version) as it was at the given timestamp.
|
|
147
|
+
def version_at(timestamp, reify_options={})
|
|
148
|
+
# Because a version stores how its object looked *before* the change,
|
|
149
|
+
# we need to look for the first version created *after* the timestamp.
|
|
150
|
+
v = send(self.class.versions_association_name).following(timestamp).first
|
|
151
|
+
v ? v.reify(reify_options) : self
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Returns the objects (not Versions) as they were between the given times.
|
|
155
|
+
def versions_between(start_time, end_time, reify_options={})
|
|
156
|
+
versions = send(self.class.versions_association_name).between(start_time, end_time)
|
|
157
|
+
versions.collect { |version| version_at(version.send PaperTrail.timestamp_field) }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Returns the object (not a Version) as it was most recently.
|
|
161
|
+
def previous_version
|
|
162
|
+
preceding_version = source_version ? source_version.previous : send(self.class.versions_association_name).last
|
|
163
|
+
preceding_version.reify if preceding_version
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Returns the object (not a Version) as it became next.
|
|
167
|
+
# NOTE: if self (the item) was not reified from a version, i.e. it is the
|
|
168
|
+
# "live" item, we return nil. Perhaps we should return self instead?
|
|
169
|
+
def next_version
|
|
170
|
+
subsequent_version = source_version.next
|
|
171
|
+
subsequent_version ? subsequent_version.reify : self.class.find(self.id)
|
|
172
|
+
rescue
|
|
173
|
+
nil
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Executes the given method or block without creating a new version.
|
|
177
|
+
def without_versioning(method = nil)
|
|
178
|
+
paper_trail_was_enabled = self.paper_trail_enabled_for_model
|
|
179
|
+
self.class.paper_trail_off
|
|
180
|
+
method ? method.to_proc.call(self) : yield
|
|
181
|
+
ensure
|
|
182
|
+
self.class.paper_trail_on if paper_trail_was_enabled
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
private
|
|
186
|
+
|
|
187
|
+
def version_class
|
|
188
|
+
version_class_name.constantize
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def source_version
|
|
192
|
+
send self.class.version_association_name
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def record_create
|
|
196
|
+
if paper_trail_switched_on?
|
|
197
|
+
data = {
|
|
198
|
+
:event => paper_trail_event || 'create',
|
|
199
|
+
:whodunnit => PaperTrail.whodunnit
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if changed_notably? and version_class.column_names.include?('object_changes')
|
|
203
|
+
data[:object_changes] = PaperTrail.serializer.dump(changes_for_paper_trail)
|
|
204
|
+
end
|
|
205
|
+
send(self.class.versions_association_name).create! merge_metadata(data)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def record_update
|
|
210
|
+
if paper_trail_switched_on? && changed_notably?
|
|
211
|
+
data = {
|
|
212
|
+
:event => paper_trail_event || 'update',
|
|
213
|
+
:object => object_to_string(item_before_change),
|
|
214
|
+
:whodunnit => PaperTrail.whodunnit
|
|
215
|
+
}
|
|
216
|
+
if version_class.column_names.include? 'object_changes'
|
|
217
|
+
data[:object_changes] = PaperTrail.serializer.dump(changes_for_paper_trail)
|
|
218
|
+
end
|
|
219
|
+
send(self.class.versions_association_name).build merge_metadata(data)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def changes_for_paper_trail
|
|
224
|
+
self.changes.delete_if do |key, value|
|
|
225
|
+
!notably_changed.include?(key)
|
|
226
|
+
end.tap do |changes|
|
|
227
|
+
self.class.serialize_attribute_changes(changes) # Use serialized value for attributes when necessary
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def record_destroy
|
|
232
|
+
if paper_trail_switched_on? and not new_record?
|
|
233
|
+
version_class.create merge_metadata(:item_id => self.id,
|
|
234
|
+
:item_type => self.class.base_class.name,
|
|
235
|
+
:event => paper_trail_event || 'destroy',
|
|
236
|
+
:object => object_to_string(item_before_change),
|
|
237
|
+
:whodunnit => PaperTrail.whodunnit)
|
|
238
|
+
send(self.class.versions_association_name).send :load_target
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def merge_metadata(data)
|
|
243
|
+
# First we merge the model-level metadata in `meta`.
|
|
244
|
+
paper_trail_options[:meta].each do |k,v|
|
|
245
|
+
data[k] =
|
|
246
|
+
if v.respond_to?(:call)
|
|
247
|
+
v.call(self)
|
|
248
|
+
elsif v.is_a?(Symbol) && respond_to?(v)
|
|
249
|
+
# if it is an attribute that is changing, be sure to grab the current version
|
|
250
|
+
if has_attribute?(v) && send("#{v}_changed?".to_sym)
|
|
251
|
+
send("#{v}_was".to_sym)
|
|
252
|
+
else
|
|
253
|
+
send(v)
|
|
254
|
+
end
|
|
255
|
+
else
|
|
256
|
+
v
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
# Second we merge any extra data from the controller (if available).
|
|
260
|
+
data.merge(PaperTrail.controller_info || {})
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def item_before_change
|
|
264
|
+
previous = self.dup
|
|
265
|
+
# `dup` clears timestamps so we add them back.
|
|
266
|
+
all_timestamp_attributes.each do |column|
|
|
267
|
+
previous[column] = send(column) if respond_to?(column) && !send(column).nil?
|
|
268
|
+
end
|
|
269
|
+
previous.tap do |prev|
|
|
270
|
+
prev.id = id
|
|
271
|
+
changed_attributes.select { |k,v| self.class.column_names.include?(k) }.each { |attr, before| prev[attr] = before }
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def object_to_string(object)
|
|
276
|
+
_attrs = object.attributes.except(*self.class.paper_trail_options[:skip]).tap do |attributes|
|
|
277
|
+
self.class.serialize_attributes_for_paper_trail attributes
|
|
278
|
+
end
|
|
279
|
+
PaperTrail.serializer.dump(_attrs)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def changed_notably?
|
|
283
|
+
notably_changed.any?
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def notably_changed
|
|
287
|
+
only = self.class.paper_trail_options[:only]
|
|
288
|
+
only.empty? ? changed_and_not_ignored : (changed_and_not_ignored & only)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def changed_and_not_ignored
|
|
292
|
+
ignore = self.class.paper_trail_options[:ignore]
|
|
293
|
+
skip = self.class.paper_trail_options[:skip]
|
|
294
|
+
changed - ignore - skip
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def paper_trail_switched_on?
|
|
298
|
+
PaperTrail.enabled? && PaperTrail.enabled_for_controller? && self.class.paper_trail_enabled_for_model
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def save_version?
|
|
302
|
+
if_condition = self.class.paper_trail_options[:if]
|
|
303
|
+
unless_condition = self.class.paper_trail_options[:unless]
|
|
304
|
+
(if_condition.blank? || if_condition.call(self)) && !unless_condition.try(:call, self)
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
require 'active_support/json'
|
|
2
|
+
|
|
3
|
+
module PaperTrail
|
|
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 PaperTrail
|
|
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
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
module PaperTrail
|
|
2
|
+
class Version < ActiveRecord::Base
|
|
3
|
+
belongs_to :item, :polymorphic => true
|
|
4
|
+
validates_presence_of :event
|
|
5
|
+
attr_accessible :item_type, :item_id, :event, :whodunnit, :object, :object_changes if PaperTrail.active_record_protected_attributes?
|
|
6
|
+
|
|
7
|
+
after_create :enforce_version_limit!
|
|
8
|
+
|
|
9
|
+
def self.with_item_keys(item_type, item_id)
|
|
10
|
+
where :item_type => item_type, :item_id => item_id
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.creates
|
|
14
|
+
where :event => 'create'
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.updates
|
|
18
|
+
where :event => 'update'
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.destroys
|
|
22
|
+
where :event => 'destroy'
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.not_creates
|
|
26
|
+
where 'event <> ?', 'create'
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
scope :subsequent, lambda { |version|
|
|
30
|
+
where("#{self.primary_key} > ?", version).order("#{self.primary_key} ASC")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
scope :preceding, lambda { |version|
|
|
34
|
+
where("#{self.primary_key} < ?", version).order("#{self.primary_key} DESC")
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
scope :following, lambda { |timestamp|
|
|
38
|
+
# TODO: is this :order necessary, considering its presence on the has_many :versions association?
|
|
39
|
+
where("#{PaperTrail.timestamp_field} > ?", timestamp).
|
|
40
|
+
order("#{PaperTrail.timestamp_field} ASC, #{self.primary_key} ASC")
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
scope :between, lambda { |start_time, end_time|
|
|
44
|
+
where("#{PaperTrail.timestamp_field} > ? AND #{PaperTrail.timestamp_field} < ?", start_time, end_time).
|
|
45
|
+
order("#{PaperTrail.timestamp_field} ASC, #{self.primary_key} ASC")
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# Restore the item from this version.
|
|
49
|
+
#
|
|
50
|
+
# This will automatically restore all :has_one associations as they were "at the time",
|
|
51
|
+
# if they are also being versioned by PaperTrail. NOTE: this isn't always guaranteed
|
|
52
|
+
# to work so you can either change the lookback period (from the default 3 seconds) or
|
|
53
|
+
# opt out.
|
|
54
|
+
#
|
|
55
|
+
# Options:
|
|
56
|
+
# +:has_one+ set to `false` to opt out of has_one reification.
|
|
57
|
+
# set to a float to change the lookback time (check whether your db supports
|
|
58
|
+
# sub-second datetimes if you want them).
|
|
59
|
+
def reify(options = {})
|
|
60
|
+
without_identity_map do
|
|
61
|
+
options[:has_one] = 3 if options[:has_one] == true
|
|
62
|
+
options.reverse_merge! :has_one => false
|
|
63
|
+
|
|
64
|
+
unless object.nil?
|
|
65
|
+
attrs = PaperTrail.serializer.load object
|
|
66
|
+
|
|
67
|
+
# Normally a polymorphic belongs_to relationship allows us
|
|
68
|
+
# to get the object we belong to by calling, in this case,
|
|
69
|
+
# +item+. However this returns nil if +item+ has been
|
|
70
|
+
# destroyed, and we need to be able to retrieve destroyed
|
|
71
|
+
# objects.
|
|
72
|
+
#
|
|
73
|
+
# In this situation we constantize the +item_type+ to get hold of
|
|
74
|
+
# the class...except when the stored object's attributes
|
|
75
|
+
# include a +type+ key. If this is the case, the object
|
|
76
|
+
# we belong to is using single table inheritance and the
|
|
77
|
+
# +item_type+ will be the base class, not the actual subclass.
|
|
78
|
+
# If +type+ is present but empty, the class is the base class.
|
|
79
|
+
|
|
80
|
+
if item
|
|
81
|
+
model = item
|
|
82
|
+
# Look for attributes that exist in the model and not in this version. These attributes should be set to nil.
|
|
83
|
+
(model.attribute_names - attrs.keys).each { |k| attrs[k] = nil }
|
|
84
|
+
else
|
|
85
|
+
inheritance_column_name = item_type.constantize.inheritance_column
|
|
86
|
+
class_name = attrs[inheritance_column_name].blank? ? item_type : attrs[inheritance_column_name]
|
|
87
|
+
klass = class_name.constantize
|
|
88
|
+
model = klass.new
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
model.class.unserialize_attributes_for_paper_trail attrs
|
|
92
|
+
|
|
93
|
+
# Set all the attributes in this version on the model
|
|
94
|
+
attrs.each do |k, v|
|
|
95
|
+
if model.respond_to?("#{k}=")
|
|
96
|
+
model[k.to_sym] = v
|
|
97
|
+
else
|
|
98
|
+
logger.warn "Attribute #{k} does not exist on #{item_type} (Version id: #{id})."
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
model.send "#{model.class.version_association_name}=", self
|
|
103
|
+
|
|
104
|
+
unless options[:has_one] == false
|
|
105
|
+
reify_has_ones model, options[:has_one]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
model
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Returns what changed in this version of the item. Cf. `ActiveModel::Dirty#changes`.
|
|
114
|
+
# Returns nil if your `versions` table does not have an `object_changes` text column.
|
|
115
|
+
def changeset
|
|
116
|
+
return nil unless self.class.column_names.include? 'object_changes'
|
|
117
|
+
|
|
118
|
+
HashWithIndifferentAccess.new(PaperTrail.serializer.load(object_changes)).tap do |changes|
|
|
119
|
+
item_type.constantize.unserialize_attribute_changes(changes)
|
|
120
|
+
end
|
|
121
|
+
rescue
|
|
122
|
+
{}
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Returns who put the item into the state stored in this version.
|
|
126
|
+
def originator
|
|
127
|
+
previous.try :whodunnit
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Returns who changed the item from the state it had in this version.
|
|
131
|
+
# This is an alias for `whodunnit`.
|
|
132
|
+
def terminator
|
|
133
|
+
whodunnit
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def sibling_versions
|
|
137
|
+
self.class.with_item_keys(item_type, item_id)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def next
|
|
141
|
+
sibling_versions.subsequent(self).first
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def previous
|
|
145
|
+
sibling_versions.preceding(self).first
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def index
|
|
149
|
+
id_column = self.class.primary_key.to_sym
|
|
150
|
+
sibling_versions.select(id_column).order("#{id_column} ASC").map(&id_column).index(self.send(id_column))
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
# In Rails 3.1+, calling reify on a previous version confuses the
|
|
156
|
+
# IdentityMap, if enabled. This prevents insertion into the map.
|
|
157
|
+
def without_identity_map(&block)
|
|
158
|
+
if defined?(ActiveRecord::IdentityMap) && ActiveRecord::IdentityMap.respond_to?(:without)
|
|
159
|
+
ActiveRecord::IdentityMap.without(&block)
|
|
160
|
+
else
|
|
161
|
+
block.call
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Restore the `model`'s has_one associations as they were when this version was
|
|
166
|
+
# superseded by the next (because that's what the user was looking at when they
|
|
167
|
+
# made the change).
|
|
168
|
+
#
|
|
169
|
+
# The `lookback` sets how many seconds before the model's change we go.
|
|
170
|
+
def reify_has_ones(model, lookback)
|
|
171
|
+
model.class.reflect_on_all_associations(:has_one).each do |assoc|
|
|
172
|
+
child = model.send assoc.name
|
|
173
|
+
if child.respond_to? :version_at
|
|
174
|
+
# N.B. we use version of the child as it was `lookback` seconds before the parent was updated.
|
|
175
|
+
# Ideally we want the version of the child as it was just before the parent was updated...
|
|
176
|
+
# but until PaperTrail knows which updates are "together" (e.g. parent and child being
|
|
177
|
+
# updated on the same form), it's impossible to tell when the overall update started;
|
|
178
|
+
# and therefore impossible to know when "just before" was.
|
|
179
|
+
if (child_as_it_was = child.version_at(send(PaperTrail.timestamp_field) - lookback.seconds))
|
|
180
|
+
child_as_it_was.attributes.each do |k,v|
|
|
181
|
+
model.send(assoc.name).send :write_attribute, k.to_sym, v rescue nil
|
|
182
|
+
end
|
|
183
|
+
else
|
|
184
|
+
model.send "#{assoc.name}=", nil
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# checks to see if a value has been set for the `version_limit` config option, and if so enforces it
|
|
191
|
+
def enforce_version_limit!
|
|
192
|
+
return unless PaperTrail.config.version_limit.is_a? Numeric
|
|
193
|
+
previous_versions = sibling_versions.not_creates
|
|
194
|
+
return unless previous_versions.size > PaperTrail.config.version_limit
|
|
195
|
+
excess_previous_versions = previous_versions - previous_versions.last(PaperTrail.config.version_limit)
|
|
196
|
+
excess_previous_versions.map(&:destroy)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
end
|
|
200
|
+
end
|