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/.
Key Takeaways
- Implement Version Control: Use the PaperTrail gem to add version control capabilities to Rails applications, allowing users to track changes, undo mistakes, and review history of database records.
- Setup and Configuration: Integrate PaperTrail by adding it to the Gemfile, running the installation generator, and migrating the database to create a versions table.
- User Action Tracking: Enhance auditing capabilities by configuring PaperTrail to record user-specific information such as IP addresses and user IDs, adding accountability to changes made within the application.
- Undo and Redo Capabilities: Implement functionality to undo and redo actions with PaperTrail, providing users the ability to revert changes or restore them as needed, enhancing user control over data entries.
- Customize Tracking Options: Tailor PaperTrail’s tracking to specific needs by using options to ignore or skip tracking certain attributes, or by specifying conditions under which changes should be recorded, making the versioning system more efficient and relevant to specific application requirements.
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’sPost
.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">×</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)….
Frequently Asked Questions (FAQs) about Versioning with PaperTrail
What is the primary function of PaperTrail in versioning?
PaperTrail is a Ruby gem that helps in tracking changes to your models’ data. It’s like having a version control system at the database level. It allows you to see what a record looked like at any point in its lifecycle, who changed it, and what the changes were. This can be incredibly useful for debugging, auditing, and even in some business logic scenarios.
How do I install and set up PaperTrail?
To install PaperTrail, you need to add the gem to your Gemfile and run the bundle install command. After that, you generate an installation migration with the command rails generate paper_trail:install. This creates a versions table in your database that PaperTrail uses to store the version history. You then run the migration with the command rails db:migrate.
How do I track changes to a specific model with PaperTrail?
To track changes to a specific model, you need to add has_paper_trail to the model. This tells PaperTrail to monitor changes to instances of this model and store them in the versions table.
Can I choose which attributes to track with PaperTrail?
Yes, PaperTrail allows you to specify which attributes to track. You can do this by passing the :only or :except options to the has_paper_trail method in your model. For example, has_paper_trail only: [:name, :email] will only track changes to the name and email attributes.
How do I view the version history of a record?
You can view the version history of a record by calling the versions method on an instance of your model. This returns an array of Version objects, each representing a change to the record. You can then call the changeset method on a Version object to see what the changes were.
Can I restore a record to a previous version with PaperTrail?
Yes, PaperTrail allows you to restore a record to a previous version. You do this by calling the version_at method on an instance of your model, passing in the timestamp of the version you want to restore to.
How do I track who made changes to a record?
PaperTrail can track who made changes to a record if you tell it who the current user is. You do this by setting the PaperTrail.request.whodunnit attribute in your controller. This can be done in a before_action callback.
Can I track changes to associated records with PaperTrail?
Yes, PaperTrail can track changes to associated records. You do this by passing the :touch_with_version option to the belongs_to association in your model. This tells PaperTrail to create a new version of the parent record whenever the associated record is updated.
How do I stop tracking changes to a model with PaperTrail?
You can stop tracking changes to a model by removing the has_paper_trail method from the model. This tells PaperTrail to stop monitoring changes to instances of this model.
Can I use PaperTrail with non-ActiveRecord models?
No, PaperTrail is designed to work with ActiveRecord models. It relies on ActiveRecord’s callbacks to track changes to records. If you’re using a different ORM, you’ll need to find a different solution for versioning.
Ilya Bodrov is personal IT teacher, a senior engineer working at Campaigner LLC, author and teaching assistant at Sitepoint and lecturer at Moscow Aviations Institute. His primary programming languages are Ruby (with Rails) and JavaScript. He enjoys coding, teaching people and learning new things. Ilya also has some Cisco and Microsoft certificates and was working as a tutor in an educational center for a couple of years. In his free time he tweets, writes posts for his website, participates in OpenSource projects, goes in for sports and plays music.