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’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)….