Ruby Jobs Site (#47)

It's been proven now that you can develop functional web applications with very little time, using the right tools:

A 15-Minute Blog

I guess that puts web applications in the valid Ruby Quiz category, so let's tackle one using your framework of choice: CGI, WEBrick servlets, Rails, or whatever.

When I first came to Ruby, even just a year ago, I really doubt the community was ready to support a Ruby jobs focused web site. Now though, times have changed. I'm seeing more and more posting about Ruby jobs scattered among the various Ruby sites. Rails has obviously played no small part in this and the biggest source of jobs postings is probably the Rails weblog, but there have been other Ruby jobs offered recently as well.

Wouldn't it be nice if we had a centralized site we could go to and scan these listings for our interests?

This week's Ruby Quiz is to create a web application that allows visitors to post jobs for other visitors to find. Our focus will be on functionality at this point, so don't waste too much energy making the site beautiful. (That can be done after you decide this was a brilliant idea and you're really going to launch your site!)

What should a jobs site record for each position? I'm not going to set any hard and fast rules on this. The answer is simply: Whatever you think we should enter. If you need more ideas though, browse job listings in your local paper or check out a site like:

The Perl Job Site


Quiz Summary

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:

ruby
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:

ruby
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:

ruby
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():

ruby
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:

ruby
### 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...