First commit.

This commit is contained in:
Andy Stewart 2009-05-27 16:21:20 +01:00
commit e9a8648c22
21 changed files with 802 additions and 0 deletions

20
MIT-LICENSE Normal file
View File

@ -0,0 +1,20 @@
Copyright (c) 2009 [name of plugin creator]
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

152
README.md Normal file
View File

@ -0,0 +1,152 @@
# PaperTrail
Track changes to your models' data. Good for auditing or versioning.
## Features
* Stores every create, update and destroy.
* Does not store updates which don't change anything.
* Allows you to get at every version, including the original, even once destroyed.
* Allows you to get at every version even if the schema has since changed.
* Automatically records who was responsible if your controller has a `current_user` method.
* Allows you to set who is responsible at model-level (useful for migrations).
* Can be turned off/on (useful for migrations).
* No configuration necessary.
* Stores everything in a single database table (generates migration for you).
* Thoroughly tested.
## Rails Version
Known to work on Rails 2.3. Probably works on Rails 2.2 and 2.1.
## Basic Usage
PaperTrail is simple to use. Just add 15 characters to a model to get a paper trail of every
`create`, `update`, and `destroy`.
class Widget < ActiveRecord::Base
has_paper_trail
end
This gives you a `versions` method which returns the paper trail of changes to your model.
>> widget = Widget.find 42
>> widget.versions # [<Version>, <Version>, ...]
Once you have a version, you can find out what happened:
>> v = widget.versions.last
>> v.event # 'update' (or 'create' or 'destroy')
>> v.whodunnit # '153' (if the update was via a controller and
# the controller has a current_user method,
# here returning the id of the current user)
>> v.created_at # when the update occurred
>> widget = v.reify # the widget as it was before the update;
# would be nil for a create event
PaperTrail stores the pre-change version of the model, unlike some other auditing/versioning
plugins, so you can retrieve the original version. This is useful when you start keeping a
paper trail for models that already have records in the database.
>> widget = Widget.find 153
>> widget.name # 'Doobly'
>> widget.versions # []
>> widget.update_attributes :name => 'Wotsit'
>> widget.versions.first.reify.name # 'Doobly'
>> widget.versions.first.event # 'update'
This also means that PaperTrail does not waste space storing a version of the object as it
currently stands. The `versions` method lets you get at previous versions only; after all,
you already know what the object currently looks like.
Here's a helpful table showing what PaperTrail stores:
<table>
<tr>
<th>Event</th>
<th>Model Before</th>
<th>Model After</th>
</tr>
<tr>
<td>create</td>
<td>nil</td>
<td>widget</td>
</tr>
<tr>
<td>update</td>
<td>widget</td>
<td>widget'</td>
<tr>
<td>destroy</td>
<td>widget</td>
<td>nil</td>
</tr>
</table>
PaperTrail stores the Before column. Most other auditing/versioning plugins store the After
column.
## Finding Out Who Was Responsible For A Change
If your `ApplicationController` has a `current_user` method, PaperTrail will store the value it
returns in the `version`'s `whodunnit` column. Note that this column is a string so you will have
to convert it to an integer if it's an id and you want to look up the user later on:
>> last_change = Widget.versions.last
>> user_who_made_the_change = User.find last_change.whodunnit.to_i
In a migration or in `script/console` you can set who is responsible like this:
>> PaperTrail.whodunnit = 'Andy Stewart'
>> widget.update_attributes :name => 'Wibble'
>> widget.versions.last.whodunnit # Andy Stewart
## Turning PaperTrail Off/On
Sometimes you don't want to store changes. Perhaps you are only interested in changes made
by your users and don't need to store changes you make yourself in, say, a migration.
If you are about change some widgets and you don't want a paper trail of your changes, you can
turn PaperTrail off like this:
>> Widget.paper_trail_off
And on again like this:
>> Widget.paper_trail_on
## Installation
1. Install PaperTrail either as a gem or as a plugin:
config.gem 'airblade-paper_trail', :lib => 'paper_trail', :source => 'http://gems.github.com'
script/plugin install git://github.com/airblade/paper_trail.git
2. Generate a migration which wll add a `versions` table to your database.
script/generate paper_trail
3. Run the migration.
rake db:migrate
4. Add `has_paper_trail` to the models you want to track.
## Inspirations
* [Simply Versioned](http://github.com/github/simply_versioned)
* [Acts As Audited](http://github.com/collectiveidea/acts_as_audited)
## Intellectual Property
Copyright (c) 2009 Andy Stewart (boss@airbladesoftware.com).
Released under the MIT licence.

23
Rakefile Normal file
View File

@ -0,0 +1,23 @@
require 'rake'
require 'rake/testtask'
require 'rake/rdoctask'
desc 'Default: run unit tests.'
task :default => :test
desc 'Test the paper_trail plugin.'
Rake::TestTask.new(:test) do |t|
t.libs << 'lib'
t.libs << 'test'
t.pattern = 'test/**/*_test.rb'
t.verbose = true
end
desc 'Generate documentation for the paper_trail plugin.'
Rake::RDocTask.new(:rdoc) do |rdoc|
rdoc.rdoc_dir = 'rdoc'
rdoc.title = 'PaperTrail'
rdoc.options << '--line-numbers' << '--inline-source'
rdoc.rdoc_files.include('README')
rdoc.rdoc_files.include('lib/**/*.rb')
end

View File

@ -0,0 +1,2 @@
Description:
Generates (but does not run) a migration to add a versions table.

View File

@ -0,0 +1,9 @@
class PaperTrailGenerator < Rails::Generator::Base
def manifest
record do |m|
m.migration_template 'create_versions.rb', 'db/migrate', :migration_file_name => 'create_versions'
end
end
end

View File

@ -0,0 +1,16 @@
class CreateVersions < ActiveRecord::Migration
def self.up
create_table :versions do |t|
t.string :item_type, :null => false
t.integer :item_id, :null => false
t.string :event, :null => false
t.string :whodunnit
t.text :object
t.datetime :created_at
end
end
def self.down
drop_table :versions
end
end

1
init.rb Normal file
View File

@ -0,0 +1 @@
# Include hook code here

1
install.rb Normal file
View File

@ -0,0 +1 @@
# Install hook code here

31
lib/paper_trail.rb Normal file
View File

@ -0,0 +1,31 @@
require 'yaml'
require 'paper_trail/has_paper_trail'
require 'paper_trail/version'
module PaperTrail
VERSION = '1.0.0'
@@whodunnit = nil
def self.included(base)
base.before_filter :set_whodunnit
end
def self.whodunnit
@@whodunnit.respond_to?(:call) ? @@whodunnit.call : @@whodunnit
end
def self.whodunnit=(value)
@@whodunnit = value
end
private
def set_whodunnit
@@whodunnit = lambda {
self.respond_to?(:current_user) ? self.current_user : nil
}
end
end
ActionController::Base.send :include, PaperTrail

View File

@ -0,0 +1,70 @@
module PaperTrail
def self.included(base)
base.send :extend, ClassMethods
end
module ClassMethods
def has_paper_trail
send :include, InstanceMethods
cattr_accessor :paper_trail_active
self.paper_trail_active = true
has_many :versions, :as => :item, :order => 'created_at ASC, id ASC'
after_create :record_create
before_update :record_update
after_destroy :record_destroy
end
def paper_trail_off
self.paper_trail_active = false
end
def paper_trail_on
self.paper_trail_active = true
end
end
module InstanceMethods
def record_create
versions.create(:event => 'create',
:whodunnit => PaperTrail.whodunnit) if self.class.paper_trail_active
end
def record_update
if changed? and self.class.paper_trail_active
versions.build :event => 'update',
:object => object_to_string(previous_version),
:whodunnit => PaperTrail.whodunnit
end
end
def record_destroy
versions.create(:event => 'destroy',
:object => object_to_string(previous_version),
:whodunnit => PaperTrail.whodunnit) if self.class.paper_trail_active
end
private
def previous_version
previous = self.clone
previous.id = id
changes.each do |attr, ary|
previous.send "#{attr}=", ary.first
end
previous
end
def object_to_string(object)
object.attributes.to_yaml
end
end
end
ActiveRecord::Base.send :include, PaperTrail

View File

@ -0,0 +1,20 @@
class Version < ActiveRecord::Base
belongs_to :item, :polymorphic => true
validates_presence_of :event
def reify
unless object.nil?
# Using +item_type.constantize+ rather than +item.class+
# allows us to retrieve destroyed objects.
model = item_type.constantize.new
YAML::load(object).each do |k, v|
begin
model.send "#{k}=", v
rescue NoMethodError
RAILS_DEFAULT_LOGGER.warn "Attribute #{k} does not exist on #{item_type} (Version id: #{id})."
end
end
model
end
end
end

1
rails/init.rb Normal file
View File

@ -0,0 +1 @@
require 'paper_trail'

View File

@ -0,0 +1,16 @@
require 'rubygems'
require 'rake'
begin
require 'jeweler'
Jeweler::Tasks.new do |gemspec|
gemspec.name = 'paper_trail'
gemspec.summary = "Track changes to your models' data. Good for auditing or versioning."
gemspec.email = 'boss@airbladesoftware.com'
gemspec.homepage = 'http://github.com/airblade/paper_trail'
gemspec.authors = ['Andy Stewart']
end
rescue LoadError
puts "Jeweler not available. Install it with: sudo gem install technicalpickles-jeweler -s http://gems.github.com"
end

22
test/database.yml Normal file
View File

@ -0,0 +1,22 @@
sqlite:
:adapter: sqlite
:dbfile: vendor/plugins/paper_trail/test/paper_trail_plugin.sqlite.db
sqlite3:
:adapter: sqlite3
:dbfile: vendor/plugins/paper_trail/test/paper_trail_plugin.sqlite3.db
postgresql:
:adapter: postgresql
:username: postgres
:password: postgres
:database: paper_trail_plugin_test
:min_messages: ERROR
mysql:
:adapter: mysql
:host: localhost
:username: andy
:password:
:database: paper_trail_plugin_test
:socket: /tmp/mysql.sock

View File

@ -0,0 +1,72 @@
require File.dirname(__FILE__) + '/test_helper.rb'
require 'application_controller'
require 'action_controller/test_process'
class ApplicationController
def rescue_action(e)
raise e
end
# Returns id of hypothetical current user
def current_user
153
end
end
class WidgetsController < ApplicationController
def create
@widget = Widget.create params[:widget]
head :ok
end
def update
@widget = Widget.find params[:id]
@widget.update_attributes params[:widget]
head :ok
end
def destroy
@widget = Widget.find params[:id]
@widget.destroy
head :ok
end
end
class PaperTrailControllerTest < ActionController::TestCase #Test::Unit::TestCase
def setup
@controller = WidgetsController.new
@request = ActionController::TestRequest.new
@response = ActionController::TestResponse.new
ActionController::Routing::Routes.draw do |map|
map.resources :widgets
end
end
test 'create' do
post :create, :widget => { :name => 'Flugel' }
widget = assigns(:widget)
assert_equal 1, widget.versions.length
assert_equal 153, widget.versions.last.whodunnit.to_i
end
test 'update' do
w = Widget.create :name => 'Duvel'
assert_equal 1, w.versions.length
put :update, :id => w.id, :widget => { :name => 'Bugle' }
widget = assigns(:widget)
assert_equal 2, widget.versions.length
assert_equal 153, widget.versions.last.whodunnit.to_i
end
test 'destroy' do
w = Widget.create :name => 'Roundel'
assert_equal 1, w.versions.length
delete :destroy, :id => w.id
widget = assigns(:widget)
assert_equal 2, widget.versions.length
assert_equal 153, widget.versions.last.whodunnit.to_i
end
end

View File

@ -0,0 +1,251 @@
require File.dirname(__FILE__) + '/test_helper.rb'
class Widget < ActiveRecord::Base
has_paper_trail
end
class HasPaperTrailModelTest < Test::Unit::TestCase
load_schema
context 'A new record' do
setup { @widget = Widget.new }
should 'not have any previous versions' do
assert_equal [], @widget.versions
end
context 'which is then created' do
setup { @widget.update_attributes :name => 'Henry' }
should 'have one previous version' do
assert_equal 1, @widget.versions.length
end
should 'be nil in its previous version' do
assert_nil @widget.versions.first.object
assert_nil @widget.versions.first.reify
end
should 'record the correct event' do
assert_match /create/i, @widget.versions.first.event
end
context 'and then updated without any changes' do
setup { @widget.save }
should 'not have a new version' do
assert_equal 1, @widget.versions.length
end
end
context 'and then updated with changes' do
setup { @widget.update_attributes :name => 'Harry' }
should 'have two previous versions' do
assert_equal 2, @widget.versions.length
end
should 'be available in its previous version' do
assert_equal 'Harry', @widget.name
assert_not_nil @widget.versions.last.object
widget = @widget.versions.last.reify
assert_equal 'Henry', widget.name
assert_equal 'Harry', @widget.name
end
should 'have the same ID in its previous version' do
assert_equal @widget.id, @widget.versions.last.reify.id
end
should 'record the correct event' do
assert_match /update/i, @widget.versions.last.event
end
context 'and then destroyed' do
setup { @widget.destroy }
should 'have three previous versions' do
assert_equal 3, @widget.versions.length
end
should 'be available in its previous version' do
widget = @widget.versions.last.reify
assert_equal @widget.id, widget.id
assert_equal @widget.attributes, widget.attributes
end
should 'record the correct event' do
assert_match /destroy/i, @widget.versions.last.event
end
end
end
end
end
# Test the serialisation and unserialisation.
# TODO: binary
context "A record's papertrail" do
setup do
@date_time = DateTime.now.utc
@time = Time.now
@date = Date.new 2009, 5, 29
@widget = Widget.create :name => 'Warble',
:a_text => 'The quick brown fox',
:an_integer => 42,
:a_float => 153.01,
:a_decimal => 2.71828,
:a_datetime => @date_time,
:a_time => @time,
:a_date => @date,
:a_boolean => true
@widget.update_attributes :name => nil,
:a_text => nil,
:an_integer => nil,
:a_float => nil,
:a_decimal => nil,
:a_datetime => nil,
:a_time => nil,
:a_date => nil,
:a_boolean => false
@previous = @widget.versions.last.reify
end
should 'handle strings' do
assert_equal 'Warble', @previous.name
end
should 'handle text' do
assert_equal 'The quick brown fox', @previous.a_text
end
should 'handle integers' do
assert_equal 42, @previous.an_integer
end
should 'handle floats' do
assert_in_delta 153.01, @previous.a_float, 0.001
end
should 'handle decimals' do
assert_in_delta 2.71828, @previous.a_decimal, 0.00001
end
should 'handle datetimes' do
# Is there a better way to test equality of two datetimes?
format = '%a, %d %b %Y %H:%M:%S %z' # :rfc822
assert_equal @date_time.strftime(format), @previous.a_datetime.strftime(format)
end
should 'handle times' do
assert_equal @time, @previous.a_time
end
should 'handle dates' do
assert_equal @date, @previous.a_date
end
should 'handle booleans' do
assert @previous.a_boolean
end
context "after a column is removed from the record's schema" do
setup do
change_schema
Widget.reset_column_information
assert_raise(NoMethodError) { Widget.new.sacrificial_column }
@last = @widget.versions.last
end
should 'reify previous version' do
assert_kind_of Widget, @last.reify
end
should 'restore all forward-compatible attributes' do
format = '%a, %d %b %Y %H:%M:%S %z' # :rfc822
assert_equal 'Warble', @last.reify.name
assert_equal 'The quick brown fox', @last.reify.a_text
assert_equal 42, @last.reify.an_integer
assert_in_delta 153.01, @last.reify.a_float, 0.001
assert_in_delta 2.71828, @last.reify.a_decimal, 0.00001
assert_equal @date_time.strftime(format), @last.reify.a_datetime.strftime(format)
assert_equal @time, @last.reify.a_time
assert_equal @date, @last.reify.a_date
assert @last.reify.a_boolean
end
end
end
context 'A record' do
setup { @widget = Widget.create :name => 'Zaphod' }
context 'with its paper trail turned off' do
setup do
Widget.paper_trail_off
@count = @widget.versions.length
end
teardown { Widget.paper_trail_on }
context 'when updated' do
setup { @widget.update_attributes :name => 'Beeblebrox' }
should 'not add to its trail' do
assert_equal @count, @widget.versions.length
end
end
context 'and then its paper trail turned on' do
setup { Widget.paper_trail_on }
context 'when updated' do
setup { @widget.update_attributes :name => 'Ford' }
should 'add to its trail' do
assert_equal @count + 1, @widget.versions.length
end
end
end
end
end
context 'A papertrail with somebody making changes' do
setup do
PaperTrail.whodunnit = 'Colonel Mustard'
@widget = Widget.new :name => 'Fidget'
end
context 'when a record is created' do
setup { @widget.save }
should 'track who made the change' do
assert_equal 'Colonel Mustard', @widget.versions.last.whodunnit
end
context 'when a record is updated' do
setup { @widget.update_attributes :name => 'Rivet' }
should 'track who made the change' do
assert_equal 'Colonel Mustard', @widget.versions.last.whodunnit
end
context 'when a record is destroyed' do
setup { @widget.destroy }
should 'track who made the change' do
assert_equal 'Colonel Mustard', @widget.versions.last.whodunnit
end
end
end
end
end
end

View File

@ -0,0 +1,13 @@
require 'test_helper'
class PaperTrailTest < ActiveSupport::TestCase
def setup
load_schema
end
def test_schema_has_loaded_correctly
assert_equal [], Widget.all
assert_equal [], Version.all
assert_equal [], User.all
end
end

31
test/schema.rb Normal file
View File

@ -0,0 +1,31 @@
ActiveRecord::Schema.define(:version => 0) do
create_table :widgets, :force => true do |t|
t.string :name
t.text :a_text
t.integer :an_integer
t.float :a_float
t.decimal :a_decimal
t.datetime :a_datetime
t.time :a_time
t.date :a_date
t.boolean :a_boolean
t.datetime :created_at, :updated_at
t.string :sacrificial_column
end
create_table :users, :force => true do |t|
t.string :name
t.datetime :created_at, :updated_at
end
create_table :versions, :force => true do |t|
t.string :item_type, :null => false
t.integer :item_id, :null => false
t.string :event, :null => false
t.string :whodunnit
t.text :object
t.datetime :created_at
end
end

3
test/schema_change.rb Normal file
View File

@ -0,0 +1,3 @@
ActiveRecord::Schema.define do
remove_column :widgets, :sacrificial_column
end

47
test/test_helper.rb Normal file
View File

@ -0,0 +1,47 @@
require 'rubygems'
require 'active_support'
require 'active_support/test_case'
require 'shoulda'
ENV['RAILS_ENV'] = 'test'
ENV['RAILS_ROOT'] ||= File.dirname(__FILE__) + '/../../../..'
require 'test/unit'
require File.expand_path(File.join(ENV['RAILS_ROOT'], 'config/environment.rb'))
def connect_to_database
config = YAML::load(IO.read(File.dirname(__FILE__) + '/database.yml'))
ActiveRecord::Base.logger = Logger.new(File.dirname(__FILE__) + "/debug.log")
db_adapter = ENV['DB']
# no db passed, try one of these fine config-free DBs before bombing.
db_adapter ||=
begin
require 'rubygems'
require 'sqlite'
'sqlite'
rescue MissingSourceFile
begin
require 'sqlite3'
'sqlite3'
rescue MissingSourceFile
end
end
if db_adapter.nil?
raise "No DB Adapter selected. Pass the DB= option to pick one, or install Sqlite or Sqlite3."
end
ActiveRecord::Base.establish_connection(config[db_adapter])
end
def load_schema
connect_to_database
load(File.dirname(__FILE__) + "/schema.rb")
require File.dirname(__FILE__) + '/../rails/init.rb'
end
def change_schema
load(File.dirname(__FILE__) + "/schema_change.rb")
end

1
uninstall.rb Normal file
View File

@ -0,0 +1 @@
# Uninstall hook code here