on May 10th, 2006Keeping track of user-made changes - Part 2
In my Keeping track of user-made changes post I descriped the various options for implementing change-tracking in my application. I ended up doing something completely different. Ruby on Rails has a cool feature called Observers which basically act like database triggers. After certain events(save, update, create, etc) happen your observer code will automatically get executed.
I created a new table called audit_trails, and a corresponding AuditTrail class. Each class that needs any changes tracked needs to have a has_many association with AuditTrail. I did this using the new Rails 1.1 polymorphic association feature which is extremely useful.
-
class AuditTrail <ActiveRecord::Base
-
belongs_to :user
-
belongs_to :auditable, :polymorphic => true
-
belongs_to :approved_by, :class_name => 'User', :foreign_key => 'approved_by'
-
end
Since every change has to be approved, there's an "approved_by" relationship to the User class.
Here's the Game class that has_many AuditTrails:
-
class Game <ActiveRecord::Base
-
has_many :audit_trails, :as => :auditable, :dependent => :delete_all
-
end
Here's the AuditObserver class code:
-
class AuditObserver <ActiveRecord::Observer
-
observe Company, Game, Link, Tag, Worker
-
-
def after_create(item)
-
item.audit_trails.create(:event_type => "create", :user_id => get_user_id, :event => 'created' )
-
end
-
-
def after_destroy(item)
-
item.audit_trails.create(:event_type => "destroy", :user_id => get_user_id, :event => 'destroyed' )
-
end
-
-
def before_save(item)
-
return if item.new_record? # everything has changed if it's a new record!
-
item_before = item.class.find(item.id)
-
item_after = item
-
-
audit_text = Array.new
-
item.attributes.each do |attribute, value|
-
# don't want to log ranking changes or date last updated/created changes.
-
next if ['rating','calculated_rating','ranking_count','created_on','updated_on'].include?(attribute)
-
audit_text <<"#{attribute} changed from #{item_before[attribute]} to #{value}" if item_before[attribute] != value
-
end
-
item.audit_trails.create(:event_type => 'update', :user_id => get_user_id, :event => audit_text.inspect ) if audit_text.length> 0
-
end
-
-
protected
-
def get_user_id
-
if User.current_user.nil?
-
user_id = nil
-
else
-
user_id = User.current_user.id
-
end
-
end
-
end
The "after_create" and "after_destroy" methods automatically get called after whatever class is being observed is created or destroyed. Pretty simple stuff! Most of the complexity is in the "before_save" method when we determine what has changed and store that in a simple format that can easily be parsed and undone if an admin disapproves of the changes. The way I decided to do this was to store each change in as an element in an array with the format of "#{attribute} changed from #{item_before[attribute]} to #{value}".
Now for the undo_changes method in the AuditTrail class, which coverts the changes back into an array, loops through it, parses out the changes via a regex, and changes the updates the object back to it's original values. Piece of cake.
-
def undo_changes(approver)
-
if self.event_type.to_sym == :update
-
-
event_array = eval("#{self.event}")
-
regex = Regexp.new('([A-Za-z0-9_]+) changed from (.*) to (.*)')
-
self.approved_by = approver
-
-
event_array.each do |e|
-
if match = regex.match(e)
-
field, original_value, new_value = match.captures
-
self.auditable.update_attribute(field, original_value)
-
self.approval_status = 'approved'
-
else
-
logger.error "unable to find a match to undo changes. #{e.inspect}"
-
self.approval_status = 'error'
-
break
-
end
-
end
-
-
elsif self.event_type.to_sym == :create
-
self.auditable.destroy
-
self.approval_status = 'rejected'
-
end
-
-
self.save
-
end
Using observers and only storing the diffs, I have avoided the complexity involved with the methods described in the original article.












[…] In the application I am fixing, changes to certain objects are logged. This functionality is very similiar in thought to the code in my keeping track of user-made changes article, except the implementation is horrible. I’ll go over how he incorrectly implemented it, and then how I fixed it. […]
what happens when the object being saved doesn’t successfully save? wouldn’t a trail be created even thought he object wasn’t saved?
if this is true, then a workaround might be to not create() the audit trail, but build() it. then when the object is save()d then the trail would be saved as well.
todd
How are you implementing User.current_user? I’m having trouble with my observers because they can’t access the session or controller code. Thanks!
Dan, I used the Userstamp plugin to help me do this:
http://delynnberry.com/projects/userstamp/