Archive for December, 2008

Ruby on Rails: How to allow upload and download of files

Sunday, December 7th, 2008

This example shows how to attach a file to an item in the database.

Preparation

No special preparation is required.

Database

The item’s database schema should include the following columns:

Name Type
attachmentdata LONGBLOB
attachmenttype VARCHAR(64)
attachmentfilename VARCHAR(64) (or longer)

This can be achieved with the following migration:

def self.up
  create_table :items do |t|
    t.column :attachmentdata, :binary, :limit => 64 * 1024 * 1024
    t.column :attachmenttype, :string
    t.column :attachmentfilename, :string
    t.timestamps
  end
end

NOTE: If you change the column names, avoid using simply “type”. This will produce unexpected results and errors because Rails treats uses columns with this name to store object class names.

Model

The item’s model should include the following method, which allows the upload of a file to the database. Note that the method name is different from any of the column names. Think of it as a “virtual column”:

def attachment=(rhs)
    if ("" != rhs) then
      self.attachmentdata = rhs.read
      self.attachmenttype = rhs.content_type
      self.attachmentfilename = rhs.original_filename
    else
      self.attachmentdata = nil
      self.attachmenttype = nil
      self.attachmentfilename = nil
    end
end

View

The most important part of the view is to enable multipart in the form submitted when the item is created or edited note that the name of the file field matches the virtual column we created above:

<% form_for(@item, :html => {:multipart => true}) do |f| %>
<p>
  <label for="item_attachment">Attachment:</label>
  <%= f.file_field('attachment') %>
</p>
	<%= f.submit "Update" %>
<% end %>

To enable downloads, include links referencing the download action which we will define in the controller:

<%= link_to(@item.attachmentfilename, {:id => @item.id, :action => 'download'}) %>

Controller

Assuming the model is as described above, no changes are required to the controller for upload. For download, include the following action:

def download
    src = Source.find(params[:id])
    send_data(src.attachmentdata, :type => src.attachmenttype, :filename => src.attachmentfilename)
end

Improvements

Although the above is a simple way to upload and download files for storage in the database, it has one flaw. Normally, the edit view of the item displays input fields pre-filled with the current values. The user makes any changes, all values are sent to the server, and all the columns in the database can be re-written. Even if the user makes no changes, the current values are sent and used to update the columns.

However, attachments are not “pre-filled” in the edit view. If the user brings up an edit view and presses “Update” without making any changes, an empty filename is sent to the server, and the attachment=() method removes the attachment. This is unfriendly behavior.

A simple option is to display a Replace checkbox next to the file field in the edit view. By default, the checkbox is unchecked, meaning that the attachment is to remain untouched. If the user wants to replace the attachment, he must check the box as well as select a file. If the user wants to remove the attachment, he can check the box and leave the file field empty. This is accomplished with the following modifications to the view code shown above:

<% form_for(@item, :html => {:multipart => true}) do |f| %>
<p>
  <%= f.check_box(:replace_attachment) %> <label for="item_replace_attachment">Replace Attachment</label>
  <label for="item_attachment">Attachment:</label>
  <%= f.file_field('attachment') %>
</p>
	<%= f.submit "Update" %>
<% end %>

We don’t actually need to store the state of the checkbox in the database. It is purely a UI element. However, because forms are conveniently built from model objects, we should make the checkbox state a virtual attribute of the model class. As mentioned earlier, the checkbox is unchecked by default, so we add the following code to the model class:

  def replace_attachment
    false
  end

Naturally, you would think the body of attachment=() needs to check the state of the checkbox before altering the image columns. However, the attribute setter methods of model objects are called from ActiveRecord::Base.update_attributes(), and I know of no guarantee that they will be called in a specific order. It is therefore safer simply to store the state of the checkbox as well as the contents of the file field in the attribute setter methods, as follows:

  def replace_attachment=(rhs)
    @replace_attachment = rhs
  end

  def attachment=(rhs)
    @attachment = rhs
  end

The decision of whether or not to alter the attachment columns must be delayed to another method:

  def preprocess
    if @replace_attachment == '1'
      if (!@attachment.nil? && !@attachment.kind_of?(String))
        self.attachmentdata = @attachment.read
        self.attachmenttype = @attachment.content_type
        self.attachmentfilename = @attachment.original_filename
      else
        self.attachmentdata = nil
        self.attachmenttype = nil
        self.attachmentfilename = nil
      end
    end
  end

To ensure that the preprocess method is called after the model object’s attributes are updated from the form, but before the model object is saved to the database, simply add the following line to the top of your model class definition:

  before_save :preprocess

Ruby on Rails: How to provide a list of items that can be dragged and dropped on each other

Friday, December 5th, 2008

In this example, the drag-and-drop action results in submission of a form followed by rendering of a new page.

Preparation

No special preparation is required.

Database

It is assumed only that the database includes a table of items with identifiers.

Model

No modifications to models are required.

View

First, there is a JavaScript function to submit the form upon drag-and-drop:

<script>
  function merge(draggable, droppable, event) {
    document.merge.source.value = draggable.id;
    document.merge.destination.value = droppable.id;
    document.merge.submit();
  }
</script>

Then, there is the form referencing the action to perform upon drag-and-drop. The form tag requires a name for the JavaScript function to work:

<% form_tag('/items/merge', :name => 'merge') do -%>
    <input name="source" type="hidden">
    <input name="destination" type="hidden">
<% end -%>

Each item that can be dragged and dropped on should be in a container with a unique id.

<% for item in @items %>
	<div class=”items” id=”item_<%= item.id %>”>
		<%= item.name %>
	</div>
	<%= draggable_element(“item_” + item.id.to_s, :revert => true) %>
	<%= drop_receiving_element(“item_” + item.id.to_s,
	  :onDrop => “function(drag, drop, e) { merge(drag, drop, e); }”,
	  :hoverclass => “hover”) %>
<% end %>

Controller

The controller includes an action that extracts the IDs of the dragged and dropped-on elements:

def merge
    drag = Summary.find(params[:source].split('_')[1])
    target = Summary.find(params[:destination].split('_')[1])

After performing the necessary actions, the method can render or redirect to the necessary page.

Ruby on Rails: How to provide a list of elements that can be sorted

Wednesday, December 3rd, 2008

Preparation

No special preparation is required.

Database

It is assumed only that your database has a table of items with identifiers and an integer column in which to record the sort order. This cannot, of course, be the identifying column. In this example, the table is items and the column is priority.

Model

No changes to models are required

View

You must include the JavaScript libraries in the view. This is done with the following line:

<%= javascript_include_tag :defaults %>

The view should render a partial which will contain the items to be sorted.

<%= render :partial => ‘items’ %>

In the partial, there should be an identified container for all the items (<div id="items"> below). Each item should also be in a container with a unique ID. (It is possible to use containers other than <div> and other IDs, but the ID must end with an underscore and the actual element ID.)

<div id=”items”>
<% for item in @items %>
		<div id=”id_<%= item.id %>”>
			<%= item.name %>
		</div>
<% end %>
<%= sortable_element(“items”, :tag => ‘div’, :update => ‘items’, :url => {:action => ‘reorder’}) %>
</div><!-- items -->

Controller

The controller should include an action named in the view. In this case, it is reorder. The action should reorder the items based on the provided parameters and then re-render the partial.

def reorder
	@neworder = params[:items]

	@neworder.each_with_index { |id, priority|
		item = Item.find(id)
		item.priority = priority
		item.save()
	}

	@items = Item.find(:all, :order => “priority ASC”)
	render :partial => ‘items’
end

Ruby on Rails: How to require login for some or all actions

Monday, December 1st, 2008

Although much of your site may be public, you may want to keep parts of it only for users who have logged in. Specifically, you may want to keep certain changes behind a login screen.

Preparation

No special preparation is required.

Database

You will require a database table for registered users with the following schema:

Column Type
id Integer, Not NULL, Auto Increment
name VARCHAR(16) (or any other maximum size of user name)
password VARCHAR(64)

User Model

The user model requires Digest, which provides an MD5 hashing method by which to hash the password:

require "digest"

class User < ActiveRecord::Base
  def cleartext=(rhs)
    self.password = Digest::MD5.hexdigest(rhs)
  end

  def cleartext
    return ""
  end

  def self.authenticate(name, password)
    find(:first, :conditions => ["name = ? AND password = ?", name, Digest::MD5.hexdigest(password)])
  end
end

User View

In the form where the user is created or edited, cleartext should be used in place of password:

  <label for="user_cleartext">Password:</label>
  <%= f.text_field('cleartext', :size => 32) %>

User Controller

Once the above changes are made to the user model and view, no changes to the controller are required.

Other Models

No changes are required to other models.

Other Views

You will require a login page. The page should be named index.html for consistency with the login controller below. The page should include a form submitting to the authenticate action in the login controller:

<% form_tag 'login/authenticate' do %>

  <label for="login_name">Name:</label>
  <%= text_field_tag('name') %>

  <label for="login_password">Password:</label>
  <%= password_field_tag('password') %>

  <%= submit_tag "Log In" %>

<% end %>

Other Controllers

Your application controller should include the following method. It checks whether a valid session exists. If not, it stores the requested page in the session, and redirects to the login controller.

  protected

  def authenticate
    unless (nil != session && nil != session[:user])
      session[:return_to] = request.request_uri
      redirect_to :controller => "login"
      return false
    end
  end

The login controller’s index method will merely display the login page. Submission of the login form will call the authenticate action. If the user is successfully authenticated, then this action redirects to the page requested before authentication.

class LoginController < ActionController::Base
  def index
  end

  def authenticate
    if session[:user] = User.authenticate(params["name"], params["password"])
      if session[:return_to]
        redirect_to session[:return_to]
        session[:return_to] = nil
      else
        redirect_to :controller => "summaries"
      end
    else
      flash[:alert] = "Login failed."
      redirect_to :action => "index"
    end
  end

  def logout
    reset_session
    flash[:alert] = "Logged out."
    redirect_to :action => "index"
  end
end

For controllers that you want protected behind a login page, add the following line:

before_filter :authenticate, :only => [:new, :edit, :update, :create, :destroy]

This prevents public access to controller methods that write to the database. To prevent access to all methods, omit the :only parameter.