[SUMMARY] Ruby Jobs Site (#47)

Naturally I always hope that the Ruby Quizzes are timely, but this one was maybe
too much so. One day before I released the quiz, the Ruby jobs site went live.
That probably knocked a lot of excitement out of the problem. Oh well. We can
at least look over my solution.

I built a minimal site using Rails. It didn't take too long really, though I
fiddled with a few things for a while, being picky. As a testament to the power
or Rails, I wrote very little code. I used the code generators to create the
three pieces I needed: logins, jobs, and a mailer. Then I just tweaked the
code to tie it all together.

The Rails code is spread out over the whole system, so I'm not going to recreate
it all here. You can download it, if you want to see it all or play with the
site.

Rails is an MVC framework, so the code has three layers. The model layer is
mainly defined in SQL with Rails, so here's that file:

  DROP TABLE IF EXISTS people;
  CREATE TABLE people (
      id INT NOT NULL auto_increment,
      full_name VARCHAR(100) NOT NULL,
      email VARCHAR(100) NOT NULL,
      password CHAR(40) NOT NULL,
      confirmation CHAR(6) DEFAULT NULL,
      created_on DATE NOT NULL,
      updated_on DATE NOT NULL,
      PRIMARY KEY(id)
  );
  
  DROP TABLE IF EXISTS jobs;
  CREATE TABLE jobs (
      id INT NOT NULL auto_increment,
      person_id INT NOT NULL,
      company VARCHAR(100) NOT NULL,
      country VARCHAR(100) NOT NULL,
      state VARCHAR(100) NOT NULL,
      city VARCHAR(100) NOT NULL,
      pay VARCHAR(50) NOT NULL,
      terms ENUM( 'contract',
                            'hourly',
                            'salaried' ) NOT NULL,
      on_site ENUM( 'none',
                            'some',
                            'all' ) NOT NULL,
      hours VARCHAR(50) NOT NULL,
      travel VARCHAR(50) NOT NULL,
      description TEXT NOT NULL,
      required_skills TEXT NOT NULL,
      desired_skills TEXT,
      how_to_apply TEXT NOT NULL,
      created_on DATE NOT NULL,
      updated_on DATE NOT NULL,
      PRIMARY KEY(id)
  );

I wrote that for MySQL, but it's pretty simple SQL and I assume it would work
with few changes in most databases. The id fields are the unique identifiers
Rails likes, created_on and updated_on are date fields Rails can maintain for
you, and the rest is the actual data of my application.

Wrapping ActiveRecord around the jobs table was trivial:

  class Job < ActiveRecord::Base
    belongs_to :person
    
    ON_SITE_CHOICES = %w{none some all}
    TERMS_CHOICES = %w{contract hourly salaried}
    STATE_CHOICES = %w{ Alabama Alaska Arizona Arkansas California
                          Colorado Connecticut Delaware Florida Georgia
                          Hawaii Idaho Illinois Indiana Iowa Kansas Kentucky
                          Louisiana Maine Maryland Massachusetts Michigan
                          Minnesota Mississippi Missouri Montana Nebraska
                          Nevada New\ Hampshire New\ Jersey New\ Mexico
                          New\ York North\ Carolina North\ Dakota Ohio
                          Oklahoma Oregon Pennsylvania Rhode\ Island
                          South\ Carolina South\ Dakota Tennessee Texas Utah
                          Vermont Virginia Washington West\ Virginia
                          Wisconsin Wyoming Other }
              
    validates_inclusion_of :on_site, :in => ON_SITE_CHOICES
    
    validates_inclusion_of :terms, :in => TERMS_CHOICES
    
    validates_presence_of :company, :on_site, :terms,
                          :country, :state, :city,
                          :pay, :hours, :description, :required_skills,
                          :how_to_apply, :person_id
              
    def location
      "#{city}, #{state} (#{country})"
    end
  end

Most of that is just some constants I use to build menus later in the view. You
can see my basic validations in there as well. I also defined my own attribute
of location() which is just a combination of city, state, and country.

Wrapping people wasn't much different. I used the login generator to create
them, but renamed User to Person. That seemed to fit better with my idea of
building a site to collection information on Ruby people, jobs, groups, and
events. I did away with the concept of a login name in favor of email addresses
as a unique identifier. I also added an email confirmation to the login system,
so I'll show that here:

  class Person < ActiveRecord::Base
    # ...
    
    def self.authenticate( email, password, confirmation )
      person = find_first( [ "email = ? AND password = ?",
                             email, sha1(password) ] )
      return nil if person.nil?
      unless person.confirmation.blank?
        if confirmation == person.confirmation
          person.confirmation = nil
          person.save or raise "Unable to remove confirmation."
          person
        else
          false
        end
      end
    end

    protected
    
    # ...

    before_create :generate_confirmation

    def generate_confirmation
      code_chars = ("A".."Z").to_a + ("a".."z").to_a + (0..9).to_a
      code = Array.new(6) { code_chars[rand(code_chars.size)] }.join
      write_attribute "confirmation", code
    end
  end

You can see at the bottom that I added a filter to add random confirmation codes
to new people. I enhanced authenticate() to later verify the code and remove
it, showing a trusted email address. An ActionMailer instance (not shown) sent
the code to the person and the login form (not shown) was changed to read it on
the first login.

I made other changes to the login system. I had it store just the Person.id()
in the session, instead of the whole Person. I also added a login_optional()
filter, that uses information when available, but doesn't require it. All of
these were trivial to implement and are not shown here.

The controller layer is hardly worth talking about. The scaffold generator
truly gave me most of what I needed in this simple case. I added the login
filters and modified create() to handle my unusual form that allows you to menu
select a state in the U.S., or enter your own. Here's a peak at those changes:

  class JobController < ApplicationController
    before_filter :login_required, :except => [:index, :list, :show]
    before_filter :login_optional, :only => [:show]
    
    # ...
    
    def create
      @job = Job.new(params[:job])
      @job.person_id = @person.id
      @job.state = params[:other_state] if @job.state == "Other"
      if @job.save
        flash[:notice] = "Job was successfully created."
        redirect_to :action => "list"
      else
        render :action => "new"
      end
    end
    
    # ...
  end

All very basic, as you can see. If the state() attribute of the job was set to
"Other", I just swap it out for the text field.

My views were also mostly just cleaned up versions of the stuff Rails generated
for me. Here's a peak at the job list view:

  <h2>Listing jobs</h2>
  
  <% if @jobs.nil? or @jobs.empty? -%>
  <p>No jobs listed, currently. Check back soon.</p>
  <% else -%>
  <% @jobs.each do |job| -%>
  <dl>
      <dt>Posted:</dt>
      <dd><%= job.created_on.strftime "%B %d, %Y" %></dd>
    
      <dt>Company:</dt>
      <dd><%= link_to h(job.company), :action => :show, :id => job %> in
          <%= h job.location %></dd>
      
      <dt>Description:</dt>
      <dd><%= excerpt(job.description, job) %></dd>
  </dl>
  <% end -%>
  <% end -%>
  
  <%= pagination_links @job_pages -%>
  
  <br />
  
  <%= link_to "List your job", :action => "new" %>

This is a basic job listing, with pagination. What this page really needs that
I didn't add is some tools to control the sorting and filtering of jobs. This
would be great for looking at jobs just in your area. The above code relies on
a helper method called excerpt():

  module JobHelper
    def excerpt( textile, id )
      html = sanitize(textilize(textile))
      html.sub!(/<p>(.*?)<\/p>(.*)\Z/m) { $1.strip }
      if $2 =~ /\S/
        "#{html} #{link_to '...', :action => :show, :id => id}"
      else
        html
      end
    end
  end

I used Redcloth to markup all the job description and skill fields. This method
allows me to grab just the first paragraph of the description, to use in the job
list view. It adds a "..." link, if content was trimmed.

Finally, I'll share one last trick. Using Rails generators and then adding the
files to Subversion can be tedious. Because of that, I added an action to the
Rakefile to do it for me:

  ### James's added tasks ###

  desc "Add generated files to Subversion"
  task :add_to_svn do
    sh %Q{svn status | ruby -nae 'puts $F[1] if $F[0] == "?"' | } +
       %Q{xargs svn add}
  end

That's just a simple nicety, but I sure like it. Saves me a lot of hassle.
Just make sure you set Subversion properties to ignore files you don't want
automatically added to the repository.

Tomorrow's Ruby Quiz is Gavin Kistner's third topic, this time on captchas...