Versioning with PaperTrail

Ilya Bodrov

arrow sign reload refresh rotation

Imagine this situation: You open your website’s admin page to do some clean up, find some old data that hasn’t been viewed by anyone for ages, and delete it. Deletion succeeds and everything is okay…but after a second… “NOOOO!!! That data contained VERY IMPORTANT INFORMATION that could possibly change the world!”, you realize. But, it’s gone and the world remains unchanged (well, there is still a chance for recovery if you have a backup of the DB :)).

Can we prevent this situation in our Rails app? Yes, we can! paper_trail to the rescue!

In this article, we are going to talk about how to implement a “History” page and an “undo” button (as well as “redo”) with the help of the paper_trail gem.

The source code is available on GitHub.

The working demo is available at http://undoer.radiant-wind.com/.

Preparing Demo Project

For this demo I am going to use Rails 4.0.4, but the same solution can be implemented with Rails 3.

We are going to build a simple weblog that allows registered users to add, update, and destroy posts. A user would be able to undo every action related to the posts (for example, undoing an unintentional delete). We will also provide a “History” page displaying a list of actions (and some other info) that users performed while working with posts.

Okay, time to get started. Create a new Rails application without the default testing suite by running:

$ rails new undoer -T

Here is the list of gems that we are going to use:

Gemfile

gem 'paper_trail', '~> 3.0.1'
gem 'bootstrap-sass', '~> 3.1.1.0'
gem 'devise'

bootstrap-sass includes Twitter Bootstrap, our old friend that will help us to style the project and devise will be used to set up some very basic authentication.

paper_trail, created by Andy Stewart, is the main star of this article – it will help us to create both the “History” page and an “undo” button. There are some other gems that provides versioning, but I find paper_trail to be the most convenient. Previously, I was using audited by Collective Idea, but it is not quite as useful and, moreover, had not been updated for 8 months. Also, it has questionable compatibility with Rails 4.

The demo app has one controller (apart from the ApplicationController), PostsController, that will be used to manage our posts. Initially, it has seven default methods: index, new, create, destroy, edit and update, along with a number of views related to them. I will not go into the details on how to create these methods and views, because they are really basic (you can create them using rails g scaffold Posts, for brevity).

The demo also contains two models: Post and User. The posts table have the following columns:

  • id
  • title
  • body
  • created_at
  • updated_at

The User model was generated by Devise and contains e-mail along with some other special fields. Again, I will not go into the details of setting up Devise, because this is not related to the article’s topic. You can use the following guide to get started: https://github.com/plataformatec/devise#getting-started. Also, feel free to any authentication approach.

The routes file looks like:

routes.rb

devise_for :users
root to: 'posts#index'
resources :posts

At this point, we are ready to setup paper_trail. First of all, create a special table to store the versions (it’s a cool feature of paper_trail – if we want to clear old versions later, we would need only to access one table).

Run these commands:

$ bundle exec rails generate paper_trail:install
$ bundle exec rake db:migrate

This will create and apply the required migration. Now, add the following line to your Post model:

models/post.rb

class Post < ActiveRecord::Base
  has_paper_trail
end

And that is all! Now, all changes to the posts table will be audited automatically. How cool is that?

You can also specify which changes should not be tracked. For example, if posts had a view_count column in and we did not want to track changes made to it, the modification looks like:

models/post.rb

has_paper_trail ignore: [:view_count]

Some fields can be skipped completely, meaning, they will neither be tracked nor included in the serialized version of the object):

models/post.rb

has_paper_trail skip: [:view_count]

Events can be specified as well:

models/post.rb

has_paper_trail on: [:update, :create]

or provide conditions on whether to track the event:

models/post.rb

has_paper_trail if: Proc.new { |t| t.title.length > 10 },
                unless: Proc.new { |t| t.body.blank? }

Simple yet powerful.

Displaying the Log

Now, you probably want to display the audited versions, eh? It’s easy, just, add the following route:

routes.rb

[...]
get '/posts/history', to: 'posts#history', as: :posts_history
resources :posts

to the history page for Posts. If your app has many models that are being audited, it’s a good idea to create a separate VersionsController and place all versions-related methods there. However, in our case, only the Post model is being audited, so let’s stick with one controller.

Add a new method to the controller:

controllers/posts_controller.rb

def history
  @versions = PaperTrail::Version.order('created_at DESC')
end

Note that we have to use PaperTrail::Version, not just Version. This line of code extracts all the recorded events from the versions table that we have created earlier and sorts them by the creation date. In a real app, paginating these events by using will_paginate or kaminari gem is advisable.

Rendering the events:

posts/history.html.erb

<div class="container">
  <h1>History</h1>

  <ul>
    <% @versions.each do |version| %>
      <li>
        <%= l(version.created_at, format: "%-d.%m.%Y %H:%M:%S %Z") %><br/>
        Event ID: <%= version.id %><br/>
        <b>Target:</b> <%= version.item_type %>
        <small>(id: <%= version.item_id %>)</small>; <b>action</b> <%= version.event %>;<br/>
        <div>
          More info:
          <pre><%= version.object %></pre>
        </div>
      </li>
    <% end %>
  </ul>
</div>

Here is the data being displayed:

  • version.created_at – When this event took place.
  • version.id – ID of this event.
  • version.item_type – Model name for the event. In our case, it’s Post.
  • version.item_id – ID of the resource (Post) that was changed.
  • version.event – The action applied to the resource (create, update, destroy).
  • version.object – Full dump of the resource that was changed.

So far, so good. Still, there are some things that could be improved. For example, which fields were changed (especially for the update action)? Well, that is very easy to implement.

Create and apply the following migration:

$ rails g migration add_object_changes_to_versions object_changes:text
$ rake db:migrate

This can also be done when setting up paper_trail. You just need to provide an appropriate option to the generator:

$ rails generate paper_trail:install --with-changes

No further action is required, paper_trail will automatically diff the versions.

Now, we can add a new div block to our view:

posts/history.html.erb

[...]
<div>
  Changeset:
  <pre><%= version.changeset %></pre>
</div>
[...]

This will display attribute values before and after the event (if an attribute remained unchanged it will not be displayed).

Tracking User-Specific Information

Okay, now we know the when of our precious blog post deletion. But, we don’t know the who! High time to fix this issue.

Let’s track an IP address of the user responsible for the action. Of course, an IP address can be forged, but the main point here is to explain how to store metadata alongside with the event’s data. Go on and create a new migration:

$ rails g migration add_ip_to_versions ip:string
$ rake db:migrate

paper_trail will not store anything in the ip column, by default, so we need to help it out a bit. Add this method to the ApplicationController:

controllers/application_controller.rb

def info_for_paper_trail
  # Save additional info
  { ip: request.remote_ip }
end

paper_trail will use this method to fetch some additional info and store it as metadata. If you are using Rails 3 or protected_attributes with Rails 4, you will also need to create an initializer:

initializers/paper_trail.rb

module PaperTrail
  class Version < ActiveRecord::Base
    attr_accessible :ip
  end
end

Metadata can also be provided in the model like this (presuming we have a timestamp column):

models/post.rb

has_paper_trail meta: { timestamp: Time.now }

The final thing to do is drop a line into the view:

posts/history.html.erb

[...]
<b>Remote address:</b> <%= version.ip %><br/>
[...]

Storing the IP is nice, but remember that we have a basic authentication system set up – couldn’t we also save the user responsible for the action? That would be very convenient! Of course, we can do that as well.

This time, we don’t need to apply a migration to the versions table, because it already contains a whodunnit column that is used to store info about the user. All we need to do is create another method in the ApplicationController:

controllers/application_controller.rb

[...]
def user_for_paper_trail
  # Save the user responsible for the action
  user_signed_in? ? current_user.id : 'Guest'
end
[...]

The user_signed_in? method is provided by Devise – basically, it tells whether the user is signed in or not. current_user is defined by Devise as well, and returns the user currently signed in as a resource (if a user is not signed in, it returns nil.) As such, we can easily fetch the current user’s id. For now, only authenticated users can manage posts in our blog, but that may change. For the not-logged-in case, we specify “Guest”.

The last thing to do is display this new info:

posts/history.html.erb

[...]
<b>User:</b>
<% if version.whodunnit && version.whodunnit != 'Guest' %>
  <% user = User.find_by_id(version.whodunnit) %>
  <% if user %>
    <%= user.email %>
    (last seen at <%= l(user.last_sign_in_at, format: "%-d.%m.%Y %H:%M:%S") %>)
  <% end %>
<% else %>
  Guest
<% end %>
[...]

Our “History” page now presents some really useful information. We can track when the event took place, what was changed, how the resource looked like before the change, and who is responsible for that change. Awesome!

Undoing an Action

Let’s move on to the second part, allowing our users to undo their actions.

For this, create a new method that will undo the requested action:

controllers/posts_controller.rb

def undo
  @post_version = PaperTrail::Version.find_by_id(params[:id])

  begin
    if @post_version.reify
      @post_version.reify.save
    else
      # For undoing the create action
      @post_version.item.destroy
    end
    flash[:success] = "Undid that!"
  rescue
    flash[:alert] = "Failed undoing the action..."
  ensure
    redirect_to root_path
  end
end

Above, find a version by id (we will generate an appropriate link later). Then, check if there are previous versions available for the resource using the reify method. This method will return nil if the resource was just created in the current version (obviously, if the resource was just created it does not have any previous versions.) Either rollback to the previous version using @post_version.reify.save or destroy the newly created resource using @post_version.item.destroy (the @post_version.item returns the actual resource). Simple, isn’t it?

Of course, we’ll need to add a new route:

routes.rb

[...]
post '/posts/:id/undo', to: 'posts#undo', as: :undo
resources :posts
[...]

The user should be presented with a link to undo his action after he did something with the post. The easiest way is to put this link into the flash message, so make sure to render it somewhere in your layout:

layouts/application.html.erb

<div class="container">
  <% flash.each do |key, value| %>
    <div class="alert alert-<%= key %>">
      <button type="button" class="close" data-dismiss="alert">&times;</button>
      <%= value.html_safe %>
    </div>
  <% end %>
</div>

Note the use of html_safe – this is required because, otherwise, our link will be rendered as raw text.

Create a private method in the PostsController that generates an undo link:

controllers/posts_controller.rb

[...]
private

def make_undo_link
  view_context.link_to 'Undo that plz!', undo_path(@post.versions.last), method: :post
end

We cannot use link_to inside the controller, so the reference to view_context points to the actual view. @post.versions fetches all the versions for the Post resource and @post.versions.last gets the latest one. This method can be used like this:

controllers/posts_controller.rb

[...]
def update
  @post = Post.find_by_id(params[:id])
  if @post.update_attributes(post_params)
    flash[:success] = "Post was updated! #{make_undo_link}"
    redirect_to post_path(@post)
  else
    render 'edit'
  end
end
[...]

Be sure to add it to the create and destroy methods as well.

Go ahead and try it in the demo app. Notice that the undoing of the action is also being tracked by the paper_trail.

Redoing an Action

Okay, I have undone an action…but now I want to redo it. Bang. Here, we should introduce a redo link that reverts the undo. There are only few modifications needed.

Create another private method:

controllers/posts_controller.rb

[...]
private

def make_redo_link
  params[:redo] == "true" ? link = "Undo that plz!" : link = "Redo that plz!"
  view_context.link_to link, undo_path(@post_version.next, redo: !params[:redo]), method: :post
end
[...]

This method is very similar to make_undo_link. The main difference is the params[:redo] which is either true of false. Based on this parameter, change the text of the link – the URL actually remains unchanged. This is because redoing basically means reverting to the previous version, which is absolutely the same as the undo action.

Alter the flash message inside the undo method:

controllers/posts_controller.rb

[...]
def undo
[...]
  flash[:success] = "Undid that! #{make_redo_link}"
[...]

That’s all! Users can undo and redo their actions as many times and they want, every time being recorded by paper_trail.

The only gotcha here is that the versions table can become fat very quickly. This should probably be handled with some background process to remove old entries. The job would use something like:

PaperTrail::Version.delete_all ["created_at < ?", 1.week.ago]

You can also limit the number of created versions per object by dropping this line into an initializer:

PaperTrail.config.version_limit = 3

Conclusion

This brings us to the end of the article. Bear in mind, you can do a lot more with paper_trail than we have discussed. For example, you can fetch versions at a given time (@post.version_at(1.day.ago)) or navigate between versions (@post.previous_version, @post.next_version) or work with model associations. You can even create a system that diffs two arbitrary versions of a resource (similar to a diffing system that is implemented on the wiki-based websites).

I hope that this article was useful and interesting for you. If not I can redo it (groan)….

Free book: Jump Start HTML5 Basics

Grab a free copy of one our latest ebooks! Packed with hints and tips on HTML5's most powerful new features.

  • Arjun Menon

    awesome tutorial!

    • Ilya Bodrov

      Thank you!

  • Ilya Bodrov

    Glad you’ve liked it :)

  • http://www.ricardoborga.com Ricardo Borghetti (Borga)

    Hello, tnks for the content. I have a question:
    I would like to restore an item from a list of changes to the registry?
    For example, inserting a button to restore a history item on posts/history.html.erb?

    Cheers!