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 +19 -0
- data/README.md +144 -0
- data/Rakefile +45 -0
- data/generators/audited_collection_migration/USAGE +10 -0
- data/generators/audited_collection_migration/audited_collection_migration_generator.rb +9 -0
- data/generators/audited_collection_migration/templates/migration.rb +21 -0
- data/init.rb +3 -0
- data/install.rb +1 -0
- data/lib/acts_as_audited_collection.rb +230 -0
- data/lib/acts_as_audited_collection/collection_audit.rb +10 -0
- data/lib/tasks/acts_as_audited_collection_tasks.rake +4 -0
- data/rails/init.rb +5 -0
- data/spec/acts_as_audited_collection_spec.rb +483 -0
- data/spec/db/acts_as_audited_collection.sqlite3.db +0 -0
- data/spec/db/database.yml +3 -0
- data/spec/db/schema.rb +47 -0
- data/spec/debug.log +56964 -0
- data/spec/models.rb +69 -0
- data/spec/spec_helper.rb +19 -0
- data/uninstall.rb +1 -0
- metadata +82 -0
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.
|
data/README.md
ADDED
@@ -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.
|
data/Rakefile
ADDED
@@ -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,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
data/install.rb
ADDED
@@ -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
|