acts_as_audited 1.0.1

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