If you’ve ever wanted to keep track of revisions to document files or images in your Rails app, you are likely to want to use Acts_as_versioned, which is the authority on versioning database records, and Attachment_fu, which is the authority on uploading files with Rails.
The problem is that they don’t know about each other and will step on each other’s toes without some changes. This article serves as a quick introduction to each, and shows how to make the two plugins get along like best friends.
Acts_as_versioned was written by Rails Core Team member Rick Olsen (who also wrote attachment_fu and Restful_authentication among others) that essentially makes a mirror table of the one you want to version, and keeps every version of the record you are updating.
Say I have a document table with fields like this:
id |
title |
description |
1 |
rep08 |
2008 report |
Acts_as_versioned will add a column “version”, and a separate table “document_versions”.
id |
title |
description |
version |
1 |
rep08 |
2008 report |
1 |
The document_versions table will look a bit like this
id |
document_id |
title |
description |
version |
1 |
1 |
rep08 |
2008 report |
1 |
Setting up acts_as_versioned is pretty simple, I got most of my introduction to it from urbanhonking.com
Now every time you update the original document, the changes are saved in your main documents table, and the version column is incremented by 1.
After a few edits of the document, you’ll see the versioning information in the Document_versions table add up.
id |
document_id |
title |
description |
version |
1 |
1 |
rep08 |
2008 report |
1 |
2 |
1 |
rep08 |
2008 report changed |
2 |
3 |
1 |
rep08 chgd |
2008 report changed |
3 |
Great! We can now use some of acts_as_versioned’s built-in methods for determining if there are older versions, and be able to view or even revert to them.
Now lets add the ability to upload a file to attach to a document record with attachment_fu.
Attachment_fu is another plugin that makes uploading files and keeping track of them in the database relatively simple.
A good intro to attachment_fu can be found on Mike Clark’s blog
Attachment_fu would require a few changes to our documents table:
id |
title |
description |
version |
filename |
content_type |
size |
1 |
rep08 |
2008 report |
1 |
rep08.jpg |
image/jpeg |
2854 |
Don’t forget to add the same fields to your documents_versions table, too.
Once we’ve added the right file fields to the new and edit forms, and image_tag or download link on the show view, we’ve got working file uploads. Nice.
Try to edit a record by attaching a new file, the new file is displayed and the record is preserved as an older version in the versioned table. But if you try to view the old version…wait a minute? Where did my version 1 file go!
That’s right, attachment_fu deletes the old file when you add a new one (as it should if you aren’t versioning your data). Attachment_fu’s rename_file method is the one responsible for deleting (or renaming) the old file when a new one is added, so lets monkeypatch that in our model to not do anything.
Now, it will only overwrite the file if the filename is the same. Lets store each version in its own folder to keep them from clobbering each other by monkey-patching the path files get written to in our model also:
def attachment_path_id
"/#{id}/v#{version}/"
end
def partitioned_path(*args)
attachment_path_id + args.to_s
end |
def attachment_path_id
"/#{id}/v#{version}/"
end
def partitioned_path(*args)
attachment_path_id + args.to_s
end
This changes the public path from /0000/0001/rep08.jpg to /1/v1/rep08.jpg
Now, if we want to display the image, we cannot use the ‘public_filename’ method, because it is only given to the Document model, and not the Document_Version model.
That’s okay, because with our new path arrangement, we can reliably predict where the old versions of the files will be kept. You can show them with some code similar to this in your views:
<% for version in @document.versions %>
Version <%= version.version %>
<%= image_tag("/documents/#{@document.id}/v#{version.version/" + version.filename) %>
<hr />
<% end %> |
<% for version in @document.versions %>
Version <%= version.version %>
<%= image_tag("/documents/#{@document.id}/v#{version.version/" + version.filename) %>
<hr />
<% end %>
Now, when we delete a record, attachment_fu only knows about the current document, and will leave behind orphaned files and folders from the old versions. Lets fix that by having it get rid of the document id folder.
Rails reserves some special methods (callbacks) for performing actions before or after other major actions, lets tap into that by defining a method that will magically get called every time we delete a record.
def after_destroy
FileUtils.rm_rf(RAILS_ROOT + "/public/documents/#{id}/")
end |
def after_destroy
FileUtils.rm_rf(RAILS_ROOT + "/public/documents/#{id}/")
end
This translates into the shell command rm -rf and deletes our ID directory and everything inside it.
Hooray!
As a wrap up, lets look at our complete Document model:
class Document < ActiveRecord::Base
acts_as_versioned
has_attachment :storage => :file_system
def rename_file
end
def attachment_path_id
"/#{id}/v#{version}/"
end
def partitioned_path(*args)
attachment_path_id + args.to_s
end
def after_destroy
FileUtils.rm_rf(RAILS_ROOT + "/public/documents/#{id}/") if id
end
end |
class Document < ActiveRecord::Base
acts_as_versioned
has_attachment :storage => :file_system
def rename_file
end
def attachment_path_id
"/#{id}/v#{version}/"
end
def partitioned_path(*args)
attachment_path_id + args.to_s
end
def after_destroy
FileUtils.rm_rf(RAILS_ROOT + "/public/documents/#{id}/") if id
end
end
I’ve whipped up a sample Rails app demonstrating the points and code in this article. It uses Rails 2.0.2 with the sqlite3 database.
Download it here: Attachments_versioned (240kb .zip)
I hope this saves some work for someone who wants to leverage these two excellent plugins by Rick Olsen (technoweenie) on the same model without having them fight too much.