acts_as_audited_collection 0.3

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.
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