acts_as_audited_collection 0.3

Sign up to get free protection for your applications and to get access to all the features.
data/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2010 Shaun Mangelsdorf
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in
11
+ all copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ THE SOFTWARE.
@@ -0,0 +1,144 @@
1
+ acts_as_audited_collection
2
+ ==========================
3
+
4
+ acts_as_audited_collection is a Rails plugin, which extends ActiveRecord to allow auditing of associations.
5
+
6
+ The basic feature set is:
7
+
8
+ - Tracking addition of child records to an association
9
+ - Tracking removal of child records
10
+ - Tracking a child record being reassociated with a new parent, as a remove followed by an add
11
+ - (Optionally) tracking any children which are modified
12
+ - (Optionally) tracking when a grandchild is modified by cascading through associations
13
+
14
+ License
15
+ -------
16
+
17
+ This plugin is released under the MIT license, and was contributed to the Rails community by the good people at [Software Projects](http://sp.com.au/).
18
+
19
+ Installation
20
+ ============
21
+
22
+ TBD
23
+
24
+ Generating the migration
25
+ ------------------------
26
+
27
+ script/generate audited_collection_migration add_collection_audits_table
28
+ rake db:migrate
29
+
30
+ Usage
31
+ =====
32
+
33
+ Declare an association that looks like:
34
+
35
+ class Employer < ActiveRecord::Base
36
+ has_many :people
37
+ acts_as_audited_collection_parent :for => :people
38
+ end
39
+
40
+ class Person < ActiveRecord::Base
41
+ belongs_to :employer
42
+ acts_as_audited_collection :parent => :employer
43
+ end
44
+
45
+ When a record is created
46
+ -------------------------
47
+
48
+ Person.create :name => 'Fred', :employer => nil # No audit record
49
+
50
+ e = Employer.create :name => 'Foo Inc.' # No audit record
51
+ Person.create :name => 'Mary', :employer => e # Audit record is created
52
+
53
+ e.people_audits.last.action # 'add'
54
+ e.people_audits.last.parent_record # Employer name: 'Foo Inc.'
55
+ e.people_audits.last.child_record # Person name: 'Mary'
56
+
57
+ Tracking removal
58
+ ----------------
59
+
60
+ e.people.create :name => 'Bob' # Audit record is created
61
+
62
+ e.people.last.destroy # Audit record is created
63
+
64
+ e.people_audits.last.action # 'remove'
65
+ e.people_audits.last.parent_record # Employer name: 'Foo Inc.'
66
+ e.people_audits.last.child_record # nil (record was destroyed)
67
+ e.people_audits.last.child_record_id # 3 (for example)
68
+ e.people_audits.last.child_record_type # 'Person'
69
+
70
+ Tracking reassociation
71
+ ----------------------
72
+
73
+ p = Person.first # Person name: 'Fred'
74
+ p.update_attributes :employer => e # Audit record is created
75
+
76
+ e.people_audits.last.action # 'add'
77
+
78
+ e2 = Employer.create :name => 'Bar Ltd.' # No audit record
79
+ p.update_attributes :employer => e2 # Two audit records!
80
+
81
+ e.people_audits.last.action # 'remove'
82
+ e2.people_audits.last.action # 'add'
83
+
84
+ p.update_attributes :employer => e # Changing it back for the sake of my own sanity.
85
+
86
+ Tracking modification of unrelated attributes
87
+ ----------------------------------------------
88
+
89
+ Consider the following alternative "Person" model.
90
+
91
+ class Person < ActiveRecord::Base
92
+ belongs_to :employer
93
+ acts_as_audited_collection :parent => :employer,
94
+ :track_modifications => true
95
+ end
96
+
97
+ With this, we can now see modifications from the parent (though we make no attempt to ascertain what the modifications were - if you need this, see [acts_as_audited](http://github.com/collectiveidea/acts_as_audited))
98
+
99
+ p = Person.first # Person name: 'Fred'
100
+ p.update_attributes :name => 'Freda' # Audit record is created
101
+
102
+ e.people_audits.last.action # 'modify'
103
+ e.people_audits.last.child_record # Person name: 'Freda'
104
+
105
+ Tracking deep changes to the model hierarchy
106
+ --------------------------------------------
107
+
108
+ Consider now that a Person might have any number of hobbies.
109
+
110
+ class Person < ActiveRecord::Base
111
+ belongs_to :employer
112
+ has_many :hobbies
113
+
114
+ acts_as_audited_collection :parent => :employer,
115
+ :track_modifications => true
116
+
117
+ acts_as_audited_collection_parent :for => :hobbies
118
+ end
119
+
120
+ class Hobby < ActiveRecord::Base
121
+ belongs_to :person
122
+
123
+ acts_as_audited_collection :parent => :person,
124
+ :cascade => true # Cascades audit events to the parent
125
+ end
126
+
127
+ The `:cascade => true` option specifies that the audit event in the child record should cascade upward, marking the parent as modified, and therefore generating a `'modify'` audit record in any grandparent for which `:track_modifications => true` has been specified..
128
+
129
+ p = Person.first # Person name: 'Freda'
130
+ p.hobbies.create :name => 'Model Trains' # Two audit records created.
131
+
132
+ p.hobbies_audits.last.action # 'add'
133
+ e.people_audits.last.action # 'modify'
134
+ e.people_audits.last.child_record # Person name: 'Freda'
135
+ e.people_audits.last.parent_record # Employer name: 'Foo Inc.'
136
+
137
+ Temporarily disabling auditing
138
+ ------------------------------
139
+
140
+ Person.without_collection_audit do
141
+ p.update_attributes :name => 'Fred' # No audit record
142
+ end
143
+
144
+ Keep in mind that this disables collection auditing completely in the current thread, not just for the `Person` model.
@@ -0,0 +1,45 @@
1
+ require 'rake'
2
+ require 'spec/rake/spectask'
3
+
4
+ require 'rubygems'
5
+ require 'rake/gempackagetask'
6
+
7
+ PKG_FILES = FileList[
8
+ '[a-zA-Z]*',
9
+ 'generators/**/*',
10
+ 'lib/**/*',
11
+ 'rails/**/*',
12
+ 'spec/**/*'
13
+ ]
14
+
15
+ spec = Gem::Specification.new do |s|
16
+ s.name = 'acts_as_audited_collection'
17
+ s.version = '0.3'
18
+ s.author = 'Shaun Mangelsdorf'
19
+ s.email = 's.mangelsdorf@gmail.com'
20
+ s.homepage = 'http://smangelsdorf.github.com'
21
+ s.platform = Gem::Platform::RUBY
22
+ s.summary = 'Extends ActiveRecord to allow auditing of associations'
23
+ s.files = PKG_FILES.to_a
24
+ s.require_path = 'lib'
25
+ s.has_rdoc = false
26
+ s.extra_rdoc_files = ['README.md']
27
+ s.rubyforge_project = 'auditcollection'
28
+ s.description = <<EOF
29
+ Adds auditing capabilities to ActiveRecord associations, in a similar fashion to acts_as_audited.
30
+ EOF
31
+ end
32
+
33
+ desc 'Default: run specs.'
34
+ task :default => :spec
35
+
36
+ desc 'Run the specs'
37
+ Spec::Rake::SpecTask.new(:spec) do |t|
38
+ t.spec_opts = ['--colour --format progress --loadby mtime --reverse']
39
+ t.spec_files = FileList['spec/**/*_spec.rb']
40
+ end
41
+
42
+ desc 'Turn this plugin into a gem.'
43
+ Rake::GemPackageTask.new(spec) do |pkg|
44
+ pkg.gem_spec = spec
45
+ end
@@ -0,0 +1,10 @@
1
+ Description:
2
+ Generates the migration to create a collection_audits table.
3
+
4
+ Example:
5
+ ./script/generate audited_collection_migration add_collection_audits_table
6
+
7
+ This will create:
8
+ db/migrate/*_add_collection_audits_table.rb
9
+
10
+ Run "rake db:migrate" to update your database.
@@ -0,0 +1,9 @@
1
+ # Released under the MIT license. See the LICENSE file for details
2
+
3
+ class AuditedCollectionMigrationGenerator < Rails::Generator::NamedBase
4
+ def manifest
5
+ record do |m|
6
+ m.migration_template 'migration.rb', "db/migrate"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,21 @@
1
+ class <%= class_name %> < ActiveRecord::Migration
2
+ def self.up
3
+ create_table :collection_audits, :force => true do |t|
4
+ t.references :parent_record, :polymorphic => {}
5
+ t.references :child_record, :polymorphic => {}
6
+ t.references :user, :polymorphic => {}
7
+ t.references :child_audit
8
+ t.string :action
9
+ t.string :association
10
+ t.datetime :created_at
11
+
12
+ t.index [:parent_record_id, :parent_record_type], :name => 'parent_record_index'
13
+ t.index [:child_record_id, :child_record_type], :name => 'child_record_index'
14
+ t.index [:user_id, :user_type], :name => 'user_index'
15
+ end
16
+ end
17
+
18
+ def self.down
19
+ drop_table :collection_audits
20
+ end
21
+ end
data/init.rb ADDED
@@ -0,0 +1,3 @@
1
+ # Released under the MIT license. See the LICENSE file for details
2
+
3
+ require File.join(File.dirname(__FILE__), 'rails', 'init')
@@ -0,0 +1 @@
1
+ # Install hook code here
@@ -0,0 +1,230 @@
1
+ # Released under the MIT license. See the LICENSE file for details
2
+
3
+ require 'acts_as_audited_collection/collection_audit.rb'
4
+
5
+ module ActiveRecord
6
+ module Acts
7
+ module AuditedCollection
8
+ def self.included(base)
9
+ base.extend ClassMethods
10
+ end
11
+
12
+ module ClassMethods
13
+ def acts_as_audited_collection(options = {})
14
+ unless self.included_modules.include?(InstanceMethods)
15
+ # First time use in this class, we have some extra work to do.
16
+ send :include, InstanceMethods
17
+
18
+ class_inheritable_reader :audited_collections
19
+ write_inheritable_attribute :audited_collections, {}
20
+ attr_accessor :collection_audit_object_is_soft_deleted
21
+
22
+ after_create :collection_audit_create
23
+ before_update :collection_audit_update
24
+ after_destroy :collection_audit_destroy
25
+
26
+ has_many :child_collection_audits, :as => :child_record,
27
+ :class_name => 'CollectionAudit'
28
+ end
29
+
30
+ options = {
31
+ :name => self.name.tableize.to_sym,
32
+ :cascade => false,
33
+ :track_modifications => false,
34
+ :only => nil,
35
+ :except => nil,
36
+ :soft_delete => nil
37
+ }.merge(options)
38
+
39
+ options[:only] &&= [options[:only]].flatten.collect(&:to_s)
40
+ options[:except] &&= [options[:except]].flatten.collect(&:to_s)
41
+
42
+ unless options.has_key? :parent
43
+ raise ActiveRecord::ConfigurationError.new "Must specify parent for an acts_as_audited_collection (:parent => :object)"
44
+ end
45
+
46
+ parent_association = reflect_on_association(options[:parent])
47
+ unless parent_association && parent_association.belongs_to?
48
+ raise ActiveRecord::ConfigurationError.new "Parent association '#{options[:parent]}' must be a belongs_to relationship"
49
+ end
50
+
51
+ # Try explicit first, then default
52
+ options[:foreign_key] ||= parent_association.options[:foreign_key]
53
+ options[:foreign_key] ||= parent_association.primary_key_name
54
+
55
+ # TODO Remove this when polymorphic is supported.
56
+ if parent_association.options[:polymorphic]
57
+ raise ActiveRecord::ConfigurationError.new "Sorry, acts_as_audited_collection polymorphic associations haven't been added yet."
58
+ end
59
+
60
+ options[:parent_type] ||= parent_association.klass.name
61
+
62
+ define_acts_as_audited_collection options do |config|
63
+ config.merge! options
64
+ end
65
+ end
66
+
67
+ def acts_as_audited_collection_parent(options = {})
68
+ unless options.has_key? :for
69
+ raise ActiveRecord::ConfigurationError.new "Must specify relationship for an acts_as_audited_collection_parent (:for => :objects)"
70
+ end
71
+
72
+ child_association = reflect_on_association(options[:for])
73
+ if child_association.nil? || child_association.belongs_to?
74
+ raise ActiveRecord::ConfigurationError.new "Association '#{options[:for]}' must be a valid parent (i.e. not belongs_to) relationship"
75
+ end
76
+
77
+ has_many :"#{options[:for]}_audits", :as => :parent_record,
78
+ :class_name => 'CollectionAudit',
79
+ :conditions => ['association = ?', options[:for].to_s]
80
+ end
81
+
82
+ def define_acts_as_audited_collection(options)
83
+ yield(read_inheritable_attribute(:audited_collections)[options[:name]] ||= {})
84
+ end
85
+
86
+ def without_collection_audit
87
+ result = nil
88
+ Thread.current[:collection_audit_enabled] = returning(Thread.current[:collection_audit_enabled]) do
89
+ Thread.current[:collection_audit_enabled] = false
90
+ result = yield if block_given?
91
+ end
92
+
93
+ result
94
+ end
95
+ end
96
+
97
+ module InstanceMethods
98
+ protected
99
+ def collection_audit_create
100
+ collection_audit_write :action => 'add', :attributes => audited_collection_attributes
101
+ end
102
+
103
+ def collection_audit_update
104
+ audited_collections.each do |name, opts|
105
+ attributes = {opts[:foreign_key] => self.send(opts[:foreign_key])}
106
+ if collection_audit_is_soft_deleted?(opts)
107
+ collection_audit_write(
108
+ :action => 'remove',
109
+ :attributes => attributes
110
+ ) unless collection_audit_was_soft_deleted?(opts)
111
+ elsif collection_audit_was_soft_deleted?(opts)
112
+ collection_audit_write :action => 'add', :attributes => attributes
113
+ end
114
+ end
115
+
116
+ unless (old_values = audited_collection_attribute_changes).empty?
117
+ new_values = old_values.inject({}) { |map, (k, v)| map[k] = self[k]; map }
118
+
119
+ collection_audit_write :action => 'remove', :attributes => old_values
120
+ collection_audit_write :action => 'add', :attributes => new_values
121
+ end
122
+
123
+ collection_audit_write_as_modified unless audited_collection_excluded_attribute_changes.empty?
124
+ end
125
+
126
+ def collection_audit_destroy
127
+ collection_audit_write :action => 'remove', :attributes => audited_collection_attributes
128
+ end
129
+
130
+ def collection_audit_write_as_modified(child_audit=nil)
131
+ each_modification_tracking_audited_collection do |col|
132
+ collection_audit_write(:action => 'modify',
133
+ :attributes => attributes.slice(col[:foreign_key]),
134
+ :child_audit => child_audit
135
+ ) if audited_collection_should_care?(col)
136
+ end
137
+ end
138
+
139
+ def collection_audit_cascade(child, child_audit)
140
+ collection_audit_write_as_modified(child_audit) if respond_to? :audited_collections
141
+ end
142
+
143
+ private
144
+ def collection_audit_is_soft_deleted?(opts)
145
+ if opts[:soft_delete]
146
+ opts[:soft_delete].all?{|k,v| self.send(k) == v}
147
+ else
148
+ false
149
+ end
150
+ end
151
+
152
+ def collection_audit_was_soft_deleted?(opts)
153
+ if opts[:soft_delete]
154
+ opts[:soft_delete].all?{|k,v| self.send(:"#{k}_was") == v}
155
+ else
156
+ false
157
+ end
158
+ end
159
+
160
+ def collection_audit_write(opts)
161
+ # Only care about explicit false here, not the falseness of nil
162
+ return if Thread.current[:collection_audit_enabled] == false
163
+
164
+ mappings = audited_relation_attribute_mappings
165
+ opts[:attributes].reject{|k,v| v.nil?}.each do |fk, fk_val|
166
+ object_being_deleted = collection_audit_is_soft_deleted?(mappings[fk]) &&
167
+ !collection_audit_was_soft_deleted?(mappings[fk])
168
+ object_being_restored = collection_audit_was_soft_deleted?(mappings[fk]) &&
169
+ !collection_audit_is_soft_deleted?(mappings[fk])
170
+ object_is_deleted = collection_audit_is_soft_deleted?(mappings[fk]) &&
171
+ collection_audit_was_soft_deleted?(mappings[fk])
172
+
173
+ unless (object_being_deleted and opts[:action] != 'remove') or
174
+ (object_being_restored and opts[:action] != 'add') or
175
+ object_is_deleted
176
+
177
+ audit = child_collection_audits.create :parent_record_id => fk_val,
178
+ :parent_record_type => mappings[fk][:parent_type],
179
+ :action => opts[:action],
180
+ :association => mappings[fk][:name].to_s,
181
+ :child_audit => opts[:child_audit]
182
+
183
+ if mappings[fk][:cascade]
184
+ parent = mappings[fk][:parent_type].constantize.send :find, fk_val
185
+ parent.collection_audit_cascade(self, audit)
186
+ end
187
+ end
188
+ end
189
+ end
190
+
191
+ def each_modification_tracking_audited_collection
192
+ audited_collections.each do |name, options|
193
+ if options[:track_modifications]
194
+ yield options
195
+ end
196
+ end
197
+ end
198
+
199
+ def audited_collection_attributes
200
+ attributes.slice *audited_relation_attribute_mappings.keys
201
+ end
202
+
203
+ def audited_collection_excluded_attribute_changes
204
+ changed_attributes.except *audited_relation_attribute_mappings.keys
205
+ end
206
+
207
+ def audited_collection_attribute_changes
208
+ changed_attributes.slice *audited_relation_attribute_mappings.keys
209
+ end
210
+
211
+ def audited_collection_should_care?(collection)
212
+ if collection[:only]
213
+ !audited_collection_excluded_attribute_changes.slice(*collection[:only]).empty?
214
+ elsif collection[:except]
215
+ !audited_collection_excluded_attribute_changes.except(*collection[:except]).empty?
216
+ else
217
+ true
218
+ end
219
+ end
220
+
221
+ def audited_relation_attribute_mappings
222
+ audited_collections.inject({}) do |map, (name, options)|
223
+ map[options[:foreign_key]] = options
224
+ map
225
+ end
226
+ end
227
+ end
228
+ end
229
+ end
230
+ end