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.

RUBY:
  1. class AuditTrail <ActiveRecord::Base
  2.   belongs_to :user
  3.   belongs_to :auditable, :polymorphic => true
  4.   belongs_to :approved_by, :class_name => 'User', :foreign_key => 'approved_by'
  5. 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:

RUBY:
  1. class Game <ActiveRecord::Base
  2.   has_many :audit_trails, :as => :auditable, :dependent => :delete_all
  3. end

Here's the AuditObserver class code:

RUBY:
  1. class AuditObserver <ActiveRecord::Observer
  2.   observe Company, Game, Link, Tag, Worker
  3.  
  4.   def after_create(item)
  5.     item.audit_trails.create(:event_type => "create", :user_id => get_user_id, :event => 'created' )
  6.   end
  7.  
  8.   def after_destroy(item)
  9.     item.audit_trails.create(:event_type => "destroy", :user_id => get_user_id, :event => 'destroyed' )
  10.   end
  11.  
  12.   def before_save(item)
  13.     return if item.new_record? # everything has changed if it's a new record!
  14.     item_before = item.class.find(item.id)
  15.     item_after = item
  16.      
  17.     audit_text = Array.new
  18.     item.attributes.each do |attribute, value|
  19.       # don't want to log ranking changes or date last updated/created changes.
  20.       next if ['rating','calculated_rating','ranking_count','created_on','updated_on'].include?(attribute)
  21.       audit_text <<"#{attribute} changed from #{item_before[attribute]} to #{value}" if item_before[attribute] != value
  22.     end
  23.     item.audit_trails.create(:event_type => 'update', :user_id => get_user_id, :event => audit_text.inspect ) if audit_text.length> 0
  24.   end
  25.  
  26.   protected
  27.   def get_user_id
  28.     if User.current_user.nil?
  29.       user_id = nil
  30.     else
  31.       user_id = User.current_user.id
  32.     end
  33.   end
  34. 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.

RUBY:
  1. def undo_changes(approver)
  2.   if self.event_type.to_sym == :update
  3.  
  4.     event_array = eval("#{self.event}")
  5.     regex = Regexp.new('([A-Za-z0-9_]+) changed from (.*) to (.*)')
  6.     self.approved_by = approver
  7.  
  8.     event_array.each do |e|
  9.       if match = regex.match(e)
  10.         field, original_value, new_value =  match.captures
  11.         self.auditable.update_attribute(field, original_value)
  12.         self.approval_status = 'approved'
  13.       else
  14.         logger.error "unable to find a match to undo changes. #{e.inspect}"
  15.         self.approval_status = 'error'
  16.         break
  17.       end
  18.     end
  19.  
  20.   elsif self.event_type.to_sym == :create
  21.     self.auditable.destroy
  22.     self.approval_status = 'rejected'
  23.   end
  24.  
  25.   self.save
  26. end

Using observers and only storing the diffs, I have avoided the complexity involved with the methods described in the original article.

4 Responses to “Keeping track of user-made changes - Part 2”

  1. […] 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. […]

  2. toddon 06 Jan 2007 at 7:09 pm

    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

  3. Dan Picketton 06 Jun 2007 at 9:56 pm

    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!

  4. Shaneon 06 Jun 2007 at 10:08 pm

    Dan, I used the Userstamp plugin to help me do this:

    http://delynnberry.com/projects/userstamp/

Trackback URI | Comments RSS

Leave a Reply