acts_as_audited 1.0.1

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.
@@ -0,0 +1,265 @@
1
+ # Copyright (c) 2006 Brandon Keepers
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining
4
+ # a copy of this software and associated documentation files (the
5
+ # "Software"), to deal in the Software without restriction, including
6
+ # without limitation the rights to use, copy, modify, merge, publish,
7
+ # distribute, sublicense, and/or sell copies of the Software, and to
8
+ # permit persons to whom the Software is furnished to do so, subject to
9
+ # the following conditions:
10
+ #
11
+ # The above copyright notice and this permission notice shall be
12
+ # included in all copies or substantial portions of the Software.
13
+ #
14
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21
+
22
+ module CollectiveIdea #:nodoc:
23
+ module Acts #:nodoc:
24
+ # Specify this act if you want changes to your model to be saved in an
25
+ # audit table. This assumes there is an audits table ready.
26
+ #
27
+ # class User < ActiveRecord::Base
28
+ # acts_as_audited
29
+ # end
30
+ #
31
+ # See <tt>CollectiveIdea::Acts::Audited::ClassMethods#acts_as_audited</tt>
32
+ # for configuration options
33
+ module Audited #:nodoc:
34
+ CALLBACKS = [:audit_create, :audit_update, :audit_destroy]
35
+
36
+ def self.included(base) # :nodoc:
37
+ base.extend ClassMethods
38
+ end
39
+
40
+ module ClassMethods
41
+ # == Configuration options
42
+ #
43
+ #
44
+ # * +only+ - Only audit the given attributes
45
+ # * +except+ - Excludes fields from being saved in the audit log.
46
+ # By default, acts_as_audited will audit all but these fields:
47
+ #
48
+ # [self.primary_key, inheritance_column, 'lock_version', 'created_at', 'updated_at']
49
+ # You can add to those by passing one or an array of fields to skip.
50
+ #
51
+ # class User < ActiveRecord::Base
52
+ # acts_as_audited :except => :password
53
+ # end
54
+ # * +protect+ - If your model uses +attr_protected+, set this to false to prevent Rails from
55
+ # raising an error. If you declare +attr_accessibe+ before calling +acts_as_audited+, it
56
+ # will automatically default to false. You only need to explicitly set this if you are
57
+ # calling +attr_accessible+ after.
58
+ #
59
+ # class User < ActiveRecord::Base
60
+ # acts_as_audited :protect => false
61
+ # attr_accessible :name
62
+ # end
63
+ #
64
+ def acts_as_audited(options = {})
65
+ # don't allow multiple calls
66
+ return if self.included_modules.include?(CollectiveIdea::Acts::Audited::InstanceMethods)
67
+
68
+ options = {:protect => accessible_attributes.nil?}.merge(options)
69
+
70
+ class_inheritable_reader :non_audited_columns
71
+ class_inheritable_reader :auditing_enabled
72
+
73
+ if options[:only]
74
+ except = self.column_names - options[:only].flatten.map(&:to_s)
75
+ else
76
+ except = [self.primary_key, inheritance_column, 'lock_version', 'created_at', 'updated_at']
77
+ except |= Array(options[:except]).collect(&:to_s) if options[:except]
78
+ end
79
+ write_inheritable_attribute :non_audited_columns, except
80
+
81
+ has_many :audits, :as => :auditable, :order => "#{Audit.quoted_table_name}.version"
82
+ attr_protected :audit_ids if options[:protect]
83
+ Audit.audited_class_names << self.to_s
84
+
85
+ after_create :audit_create_callback
86
+ before_update :audit_update_callback
87
+ after_destroy :audit_destroy_callback
88
+
89
+ attr_accessor :version
90
+
91
+ extend CollectiveIdea::Acts::Audited::SingletonMethods
92
+ include CollectiveIdea::Acts::Audited::InstanceMethods
93
+
94
+ write_inheritable_attribute :auditing_enabled, true
95
+ end
96
+ end
97
+
98
+ module InstanceMethods
99
+
100
+ # Temporarily turns off auditing while saving.
101
+ def save_without_auditing
102
+ without_auditing { save }
103
+ end
104
+
105
+ # Executes the block with the auditing callbacks disabled.
106
+ #
107
+ # @foo.without_auditing do
108
+ # @foo.save
109
+ # end
110
+ #
111
+ def without_auditing(&block)
112
+ self.class.without_auditing(&block)
113
+ end
114
+
115
+ # Gets an array of the revisions available
116
+ #
117
+ # user.revisions.each do |revision|
118
+ # user.name
119
+ # user.version
120
+ # end
121
+ #
122
+ def revisions(from_version = 1)
123
+ audits = self.audits.find(:all, :conditions => ['version >= ?', from_version])
124
+ return [] if audits.empty?
125
+ revision = self.audits.find_by_version(from_version).revision
126
+ Audit.reconstruct_attributes(audits) {|attrs| revision.revision_with(attrs) }
127
+ end
128
+
129
+ # Get a specific revision specified by the version number, or +:previous+
130
+ def revision(version)
131
+ revision_with Audit.reconstruct_attributes(audits_to(version))
132
+ end
133
+
134
+ def revision_at(date_or_time)
135
+ audits = self.audits.find(:all, :conditions => ["created_at <= ?", date_or_time])
136
+ revision_with Audit.reconstruct_attributes(audits) unless audits.empty?
137
+ end
138
+
139
+ def audited_attributes
140
+ attributes.except(*non_audited_columns)
141
+ end
142
+
143
+ protected
144
+
145
+ def revision_with(attributes)
146
+ returning self.dup do |revision|
147
+ revision.send :instance_variable_set, '@attributes', self.attributes_before_type_cast
148
+ Audit.assign_revision_attributes(revision, attributes)
149
+
150
+ # Remove any association proxies so that they will be recreated
151
+ # and reference the correct object for this revision. The only way
152
+ # to determine if an instance variable is a proxy object is to
153
+ # see if it responds to certain methods, as it forwards almost
154
+ # everything to its target.
155
+ for ivar in revision.instance_variables
156
+ proxy = revision.instance_variable_get ivar
157
+ if !proxy.nil? and proxy.respond_to? :proxy_respond_to?
158
+ revision.instance_variable_set ivar, nil
159
+ end
160
+ end
161
+ end
162
+ end
163
+
164
+ private
165
+
166
+ def audited_changes
167
+ changed_attributes.except(*non_audited_columns).inject({}) do |changes,(attr, old_value)|
168
+ changes[attr] = [old_value, self[attr]]
169
+ changes
170
+ end
171
+ end
172
+
173
+ def audits_to(version = nil)
174
+ if version == :previous
175
+ version = if self.version
176
+ self.version - 1
177
+ else
178
+ previous = audits.find(:first, :offset => 1,
179
+ :order => "#{Audit.quoted_table_name}.version DESC")
180
+ previous ? previous.version : 1
181
+ end
182
+ end
183
+ audits.find(:all, :conditions => ['version <= ?', version])
184
+ end
185
+
186
+ def audit_create(user = nil)
187
+ write_audit(:action => 'create', :changes => audited_attributes, :user => user)
188
+ end
189
+
190
+ def audit_update(user = nil)
191
+ unless (changes = audited_changes).empty?
192
+ write_audit(:action => 'update', :changes => changes, :user => user)
193
+ end
194
+ end
195
+
196
+ def audit_destroy(user = nil)
197
+ write_audit(:action => 'destroy', :user => user, :changes => audited_attributes)
198
+ end
199
+
200
+ def write_audit(attrs)
201
+ self.audits.create attrs if auditing_enabled
202
+ end
203
+
204
+ CALLBACKS.each do |attr_name|
205
+ alias_method "#{attr_name}_callback".to_sym, attr_name
206
+ end
207
+
208
+ def empty_callback #:nodoc:
209
+ end
210
+
211
+ end # InstanceMethods
212
+
213
+ module SingletonMethods
214
+ # Returns an array of columns that are audited. See non_audited_columns
215
+ def audited_columns
216
+ self.columns.select { |c| !non_audited_columns.include?(c.name) }
217
+ end
218
+
219
+ # Executes the block with auditing disabled.
220
+ #
221
+ # Foo.without_auditing do
222
+ # @foo.save
223
+ # end
224
+ #
225
+ def without_auditing(&block)
226
+ auditing_was_enabled = auditing_enabled
227
+ disable_auditing
228
+ returning(block.call) { enable_auditing if auditing_was_enabled }
229
+ end
230
+
231
+ def disable_auditing
232
+ write_inheritable_attribute :auditing_enabled, false
233
+ end
234
+
235
+ def enable_auditing
236
+ write_inheritable_attribute :auditing_enabled, true
237
+ end
238
+
239
+ def disable_auditing_callbacks
240
+ class_eval do
241
+ CALLBACKS.each do |attr_name|
242
+ alias_method "#{attr_name}_callback", :empty_callback
243
+ end
244
+ end
245
+ end
246
+
247
+ def enable_auditing_callbacks
248
+ class_eval do
249
+ CALLBACKS.each do |attr_name|
250
+ alias_method "#{attr_name}_callback".to_sym, attr_name
251
+ end
252
+ end
253
+ end
254
+
255
+ # All audit operations during the block are recorded as being
256
+ # made by +user+. This is not model specific, the method is a
257
+ # convenience wrapper around #Audit.as_user.
258
+ def audit_as( user, &block )
259
+ Audit.as_user( user, &block )
260
+ end
261
+
262
+ end
263
+ end
264
+ end
265
+ end
data/rails/init.rb ADDED
@@ -0,0 +1,9 @@
1
+ require 'acts_as_audited/audit'
2
+ require 'acts_as_audited'
3
+
4
+ ActiveRecord::Base.send :include, CollectiveIdea::Acts::Audited
5
+
6
+ if defined?(ActionController) and defined?(ActionController::Base)
7
+ require 'acts_as_audited/audit_sweeper'
8
+ ActionController::Base.send :include, CollectiveIdea::ActionController::Audited
9
+ end
@@ -0,0 +1,365 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/test_helper')
2
+
3
+ module CollectiveIdea
4
+ module Acts
5
+ class AuditedTest < Test::Unit::TestCase
6
+ should "include instance methods" do
7
+ User.new.should be_kind_of(CollectiveIdea::Acts::Audited::InstanceMethods)
8
+ end
9
+
10
+ should "extend singleton methods" do
11
+ User.should be_kind_of(CollectiveIdea::Acts::Audited::SingletonMethods)
12
+ end
13
+
14
+ ['created_at', 'updated_at', 'lock_version', 'id', 'password'].each do |column|
15
+ should "not audit #{column}" do
16
+ User.non_audited_columns.should include(column)
17
+ end
18
+ end
19
+
20
+ should "not save non-audited columns" do
21
+ create_user.audits.first.changes.keys.any?{|col| ['created_at', 'updated_at', 'password'].include? col}.should be(false)
22
+ end
23
+
24
+ context "on create" do
25
+ setup { @user = create_user }
26
+
27
+ should_change 'Audit.count', :by => 1
28
+
29
+ should 'create associated audit' do
30
+ @user.audits.count.should == 1
31
+ end
32
+
33
+ should "set the action to 'create'" do
34
+ @user.audits.first.action.should == 'create'
35
+ end
36
+
37
+ should "store all the audited attributes" do
38
+ @user.audits.first.changes.should == @user.audited_attributes
39
+ end
40
+ end
41
+
42
+ context "on update" do
43
+ setup do
44
+ @user = create_user(:name => 'Brandon')
45
+ end
46
+
47
+ should "save an audit" do
48
+ lambda { @user.update_attribute(:name, "Someone") }.should change { @user.audits.count }.by(1)
49
+ lambda { @user.update_attribute(:name, "Someone else") }.should change { @user.audits.count }.by(1)
50
+ end
51
+
52
+ should "not save an audit if the record is not changed" do
53
+ lambda { @user.save! }.should_not change { Audit.count }
54
+ end
55
+
56
+ should "set the action to 'update'" do
57
+ @user.update_attributes :name => 'Changed'
58
+ @user.audits.last.action.should == 'update'
59
+ end
60
+
61
+ should "store the changed attributes" do
62
+ @user.update_attributes :name => 'Changed'
63
+ @user.audits.last.changes.should == {'name' => ['Brandon', 'Changed']}
64
+ end
65
+
66
+ # Dirty tracking in Rails 2.0-2.2 had issues with type casting
67
+ if ActiveRecord::VERSION::STRING >= '2.3'
68
+ should "not save an audit if the value doesn't change after type casting" do
69
+ @user.update_attributes! :logins => 0, :activated => true
70
+ lambda { @user.update_attribute :logins, '0' }.should_not change { Audit.count }
71
+ lambda { @user.update_attribute :activated, 1 }.should_not change { Audit.count }
72
+ lambda { @user.update_attribute :activated, '1' }.should_not change { Audit.count }
73
+ end
74
+ end
75
+
76
+ end
77
+
78
+ context "on destroy" do
79
+ setup do
80
+ @user = create_user
81
+ end
82
+
83
+ should "save an audit" do
84
+ lambda { @user.destroy }.should change { Audit.count }.by(1)
85
+ @user.audits.size.should == 2
86
+ end
87
+
88
+ should "set the action to 'destroy'" do
89
+ @user.destroy
90
+ @user.audits.last.action.should == 'destroy'
91
+ end
92
+
93
+ should "store all of the audited attributes" do
94
+ @user.destroy
95
+ @user.audits.last.changes.should == @user.audited_attributes
96
+ end
97
+
98
+ should "be able to reconstruct destroyed record without history" do
99
+ @user.audits.delete_all
100
+ @user.destroy
101
+ revision = @user.audits.first.revision
102
+ revision.name.should == @user.name
103
+ end
104
+ end
105
+
106
+ context "dirty tracking" do
107
+ setup do
108
+ @user = create_user
109
+ end
110
+
111
+ should "not be changed when the record is saved" do
112
+ u = User.new(:name => 'Brandon')
113
+ u.changed?.should be(true)
114
+ u.save
115
+ u.changed?.should be(false)
116
+ end
117
+
118
+ should "be changed when an attribute has been changed" do
119
+ @user.name = "Bobby"
120
+ @user.changed?.should be(true)
121
+ @user.name_changed?.should be(true)
122
+ @user.username_changed?.should be(false)
123
+ end
124
+
125
+ # Dirty tracking in Rails 2.0-2.2 had issues with type casting
126
+ if ActiveRecord::VERSION::STRING >= '2.3'
127
+ should "not be changed if the value doesn't change after type casting" do
128
+ @user.update_attributes! :logins => 0, :activated => true
129
+ @user.logins = '0'
130
+ @user.changed?.should be(false)
131
+ end
132
+ end
133
+
134
+ end
135
+
136
+ context "revisions" do
137
+ setup do
138
+ @user = create_versions
139
+ end
140
+
141
+ should "be an Array of Users" do
142
+ @user.revisions.should be_kind_of(Array)
143
+ @user.revisions.each {|version| version.should be_kind_of(User) }
144
+ end
145
+
146
+ should "have one revision for a new record" do
147
+ create_user.revisions.size.should == 1
148
+ end
149
+
150
+ should "have one revision for each audit" do
151
+ @user.revisions.size.should == @user.audits.size
152
+ end
153
+
154
+ should "set the attributes for each revision" do
155
+ u = User.create(:name => 'Brandon', :username => 'brandon')
156
+ u.update_attributes :name => 'Foobar'
157
+ u.update_attributes :name => 'Awesome', :username => 'keepers'
158
+
159
+ u.revisions.size.should == 3
160
+
161
+ u.revisions[0].name.should == 'Brandon'
162
+ u.revisions[0].username.should == 'brandon'
163
+
164
+ u.revisions[1].name.should == 'Foobar'
165
+ u.revisions[1].username.should == 'brandon'
166
+
167
+ u.revisions[2].name.should == 'Awesome'
168
+ u.revisions[2].username.should == 'keepers'
169
+ end
170
+
171
+ should "access to only recent revisions" do
172
+ u = User.create(:name => 'Brandon', :username => 'brandon')
173
+ u.update_attributes :name => 'Foobar'
174
+ u.update_attributes :name => 'Awesome', :username => 'keepers'
175
+
176
+ u.revisions(2).size.should == 2
177
+
178
+ u.revisions(2)[0].name.should == 'Foobar'
179
+ u.revisions(2)[0].username.should == 'brandon'
180
+
181
+ u.revisions(2)[1].name.should == 'Awesome'
182
+ u.revisions(2)[1].username.should == 'keepers'
183
+ end
184
+
185
+ should "be empty if no audits exist" do
186
+ @user.audits.delete_all
187
+ @user.revisions.empty?.should be(true)
188
+ end
189
+
190
+ should "ignore attributes that have been deleted" do
191
+ @user.audits.last.update_attributes :changes => {:old_attribute => 'old value'}
192
+ lambda { @user.revisions }.should_not raise_error
193
+ end
194
+
195
+ end
196
+
197
+ context "revision" do
198
+ setup do
199
+ @user = create_versions(5)
200
+ end
201
+
202
+ should "maintain identity" do
203
+ @user.revision(1).should == @user
204
+ end
205
+
206
+ should "find the given revision" do
207
+ revision = @user.revision(3)
208
+ revision.should be_kind_of(User)
209
+ revision.version.should == 3
210
+ revision.name.should == 'Foobar 3'
211
+ end
212
+
213
+ should "find the previous revision with :previous" do
214
+ revision = @user.revision(:previous)
215
+ revision.version.should == 4
216
+ revision.should == @user.revision(4)
217
+ end
218
+
219
+ should "be able to get the previous revision repeatedly" do
220
+ previous = @user.revision(:previous)
221
+ previous.version.should == 4
222
+ previous.revision(:previous).version.should == 3
223
+ end
224
+
225
+ should "be able to set protected attributes" do
226
+ u = User.create(:name => 'Brandon')
227
+ u.update_attribute :logins, 1
228
+ u.update_attribute :logins, 2
229
+
230
+ u.revision(3).logins.should == 2
231
+ u.revision(2).logins.should == 1
232
+ u.revision(1).logins.should == 0
233
+ end
234
+
235
+ should "set attributes directly" do
236
+ u = User.create(:name => '<Joe>')
237
+ u.revision(1).name.should == '&lt;Joe&gt;'
238
+ end
239
+
240
+ should "set the attributes for each revision" do
241
+ u = User.create(:name => 'Brandon', :username => 'brandon')
242
+ u.update_attributes :name => 'Foobar'
243
+ u.update_attributes :name => 'Awesome', :username => 'keepers'
244
+
245
+ u.revision(3).name.should == 'Awesome'
246
+ u.revision(3).username.should == 'keepers'
247
+
248
+ u.revision(2).name.should == 'Foobar'
249
+ u.revision(2).username.should == 'brandon'
250
+
251
+ u.revision(1).name.should == 'Brandon'
252
+ u.revision(1).username.should == 'brandon'
253
+ end
254
+
255
+ should "not raise an error when no previous audits exist" do
256
+ @user.audits.destroy_all
257
+ lambda{ @user.revision(:previous) }.should_not raise_error
258
+ end
259
+
260
+ should "mark revision's attributes as changed" do
261
+ @user.revision(1).name_changed?.should be(true)
262
+ end
263
+
264
+ should "record new audit when saving revision" do
265
+ lambda { @user.revision(1).save! }.should change { @user.audits.count }.by(1)
266
+ end
267
+
268
+ end
269
+
270
+ context "revision_at" do
271
+ should "find the latest revision before the given time" do
272
+ u = create_user
273
+ Audit.update(u.audits.first.id, :created_at => 1.hour.ago)
274
+ u.update_attributes :name => 'updated'
275
+ u.revision_at(2.minutes.ago).version.should == 1
276
+ end
277
+
278
+ should "be nil if given a time before audits" do
279
+ create_user.revision_at(1.week.ago).should be(nil)
280
+ end
281
+
282
+ end
283
+
284
+ context "without auditing" do
285
+
286
+ should "not save an audit when calling #save_without_auditing" do
287
+ lambda {
288
+ u = User.new(:name => 'Brandon')
289
+ u.save_without_auditing.should be(true)
290
+ }.should_not change { Audit.count }
291
+ end
292
+
293
+ should "not save an audit inside of the #without_auditing block" do
294
+ lambda do
295
+ User.without_auditing { User.create(:name => 'Brandon') }
296
+ end.should_not change { Audit.count }
297
+ end
298
+
299
+ should "not save an audit when callbacks are disabled" do
300
+ begin
301
+ User.disable_auditing_callbacks
302
+ lambda { create_user }.should_not change { Audit.count }
303
+ ensure
304
+ User.enable_auditing_callbacks
305
+ end
306
+ end
307
+ end
308
+
309
+ context "attr_protected and attr_accessible" do
310
+ class UnprotectedUser < ActiveRecord::Base
311
+ set_table_name :users
312
+ acts_as_audited :protect => false
313
+ attr_accessible :name, :username, :password
314
+ end
315
+ should "not raise error when attr_accessible is set and protected is false" do
316
+ lambda{
317
+ UnprotectedUser.new(:name => 'NO FAIL!')
318
+ }.should_not raise_error(RuntimeError)
319
+ end
320
+
321
+ class AccessibleUser < ActiveRecord::Base
322
+ set_table_name :users
323
+ attr_accessible :name, :username, :password # declare attr_accessible before calling aaa
324
+ acts_as_audited
325
+ end
326
+ should "not raise an error when attr_accessible is declared before acts_as_audited" do
327
+ lambda{
328
+ AccessibleUser.new(:name => 'NO FAIL!')
329
+ }.should_not raise_error
330
+ end
331
+ end
332
+
333
+ context "audit as" do
334
+ setup do
335
+ @user = User.create :name => 'Testing'
336
+ end
337
+
338
+ should "record user objects" do
339
+ Company.audit_as( @user ) do
340
+ company = Company.create :name => 'The auditors'
341
+ company.name = 'The Auditors'
342
+ company.save
343
+
344
+ company.audits.each do |audit|
345
+ audit.user.should == @user
346
+ end
347
+ end
348
+ end
349
+
350
+ should "record usernames" do
351
+ Company.audit_as( @user.name ) do
352
+ company = Company.create :name => 'The auditors'
353
+ company.name = 'The Auditors, Inc'
354
+ company.save
355
+
356
+ company.audits.each do |audit|
357
+ audit.username.should == @user.name
358
+ end
359
+ end
360
+ end
361
+ end
362
+
363
+ end
364
+ end
365
+ end
@@ -0,0 +1,29 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/test_helper')
2
+
3
+ class AuditsController < ActionController::Base
4
+ audit Company
5
+
6
+ def audit
7
+ @company = Company.create
8
+ render :nothing => true
9
+ end
10
+
11
+ private
12
+ attr_accessor :current_user
13
+ end
14
+ AuditsController.view_paths = [File.dirname(__FILE__)]
15
+ ActionController::Routing::Routes.draw {|m| m.connect ':controller/:action/:id' }
16
+
17
+ class AuditsControllerTest < ActionController::TestCase
18
+
19
+ should "call acts as audited on non audited models" do
20
+ Company.should be_kind_of(CollectiveIdea::Acts::Audited::SingletonMethods)
21
+ end
22
+
23
+ should "audit user" do
24
+ user = @controller.send(:current_user=, create_user)
25
+ lambda { post :audit }.should change { Audit.count }
26
+ assigns(:company).audits.last.user.should == user
27
+ end
28
+
29
+ end