[QUIZ] Ruby Jobs Site (#47)

The three rules of Ruby Quiz:

1. Please do not post any solutions or spoiler discussion for this quiz until
48 hours have passed from the time on this message.

2. Support Ruby Quiz by submitting ideas as often as you can:

http://www.rubyquiz.com/

3. Enjoy!

···

-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=

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

  http://www.rubyonrails.com/media/video/rails_take2_with_sound.mov

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:

  http://jobs.perl.org/

What follows is a basic solution, using Rails.

The Code

···

--------

You can download the code from:

http://rubyquiz.com/jobsite.zip

Requirements
------------

If you try to play around with the site, you will need to have Redcloth installed. You will also need to edit the ActionMailer settings in development.rb to play with the login system.

The SQL for the database is included in the db folder.

How was it made?
----------------

I used the login generator to build the user portion of the code, the scaffold generator to get me started with the jobs portion of the code, and the mailer generator to prepare the confirmation email. Of course, I modified each of those. I added a confirmation email to the login system, a login_optional filter to support editing job posts, and modified the system to store only the id (not the whole user) in the session object. My changes to the other two components were more cosmetic in nature, mainly.

What could use improving?
-------------------------

Obviously, the stylesheet could use some serious attention. I did try to get the basics in there, but the site has a long way to go to become pretty.

The other big need, in my opinion, is some tools for controlling the sorting and filtering of listed positions. In this regard, I've actually considered making fields like Pay and Hours into menu selections so I could count on their formats and allow users to sort/filter based on them, in addition to the other fields.

Other Ideas
-----------

I named my site Ruby Dossiers, with a bit of a grand vision in mind.

Obviously, this quiz focuses on the job aspect of the site, but I could see adding more to it in the future. I would like to reverse the job finding aspect for starters, and allow users to post skill profiles for employers to browse. Another idea I had was to roll in local group meet-up management.

Of course, all this brainstorming was before there was a Ruby jobs site. Perhaps the ideas will at least prove useful though.

James Edward Gray II

What follows is another basic solution using ActiveRecord only and CGI.
There is minimal CSS styling, but you could add some very easily.

What is interesting about this solution is that it's very small (a
single file and a single table), does not need users and sets itself
up. The solution uses sqlite3 (via gems) and checks if the database
file exists as part of the startup. If it doesn't exist, it creates the
file and the table that it needs.

Listing, searching, viewing details, creating new posts and closing
posts are supported. Each new post generates a 'secret' that the person
posting can then use to close the post with latter, such that users are
not required. Posts can also be 'administratively' closed.

Additionally, although this solution is a single file, all the
interfaces are templated using ERB. Each template is a separate entry
after the __END__ marker, with the first non-whitespace line being the
name and all lines after until the separater line as the file contents.
DRY principles are also in place as the header/footer are seperate
templates and included into each page rather then being repeated.

···

----------------------
Copy the following and paste it into a .cgi file. It has been tested
with lighttpd and apache.
----------------------

#!/usr/bin/env ruby

## Proposed solution to http://www.rubyquiz.org/quiz047.html
## Written by Paul Vaillant (paul.vaillant@gmail.com)
## Permission granted to do whatever you'd like with this code

require 'digest/md5'
require 'cgi'
require 'erb'

## gems are required for sqlite3 and active_record
require 'rubygems'
require 'sqlite3'
require 'active_record'

## Check if the database exists; create it and the table we need if it
doesn't
DB_FILE = "/tmp/jobs.db"
unless File.readable?(DB_FILE)
  table_def = <<-EOD
CREATE TABLE postings (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        posted INTEGER,
  title VARCHAR(255),
        company VARCHAR(255),
  location VARCHAR(255),
        length VARCHAR(255),
        contact VARCHAR(255),
        travel INTEGER(2), -- 0%, 0-25%, 25-50%, 50-75%, 75-100%
        onsite INTEGER(1),
        description TEXT,
        requirements TEXT,
        terms INTEGER(2), -- C(hourly), C(project), E(hourly), E(pt),
E(ft)
        hours VARCHAR(255),
        secret VARCHAR(255) UNIQUE,
        closed INTEGER(1) DEFAULT 0
);
EOD
  db = SQLite3::Database.new(DB_FILE)
  db.execute(table_def)
  db.close
end

## Setup ActiveRecord database connection and the one ORM class we need
ActiveRecord::Base.establish_connection(:adapter => "sqlite3", :dbfile
=> DB_FILE)
class Posting < ActiveRecord::Base
  TRAVEL = ['0%','0-25%','25-50%','50-75%','75-100%']
  TERMS = ['Contract(hourly)','Contract(project)','Employee(hourly)',
    'Employee(part-time)','Employee(full-time)']
end

class Actions
  ADMIN_SECRET = 's3cr3t'
  @@templates = nil
  def self.template(t)
    unless @@templates
      @@templates = Hash.new
      name = nil
      data = ''
      DATA.each_line {|l|
        if name.nil?
          name = l.strip
        elsif l.strip == '-=-=-=-=-'
          @@templates[name] = data if name
          name = nil
          data = ''
        else
          data << l.strip << "\n"
        end unless l =~ /^\s*$/
      }
      @@templates[name] = data if name
    end
    return @@templates[t]
  end

  def self.dispatch()
    cgi = CGI.new
    begin
      ## map path_info to the method that handles it (ie controller)
      ## ex. no path_info (/jobs.cgi) goes to 'index'
      ## /search (/jobs.cgi/search) goes to 'search'
      ## /create/save (/jobs.cgi/create/save) goes to 'create__save'
      action = if cgi.path_info
          a = cgi.path_info[1,cgi.path_info.length-1].gsub(/\//,'__')
          (a && a != '' ? a : 'index')
        else
          "index"
        end
      a = Actions.new(cgi)
      m = a.method(action.to_sym)
      if m && m.arity == 0
        resbody = m.call()
      else
        raise "Failed to locate valid handler for [#{action}]"
      end
    rescue Exception => e
      puts cgi.header('text/plain')
      puts "EXCEPTION: #{e.message}"
      puts e.backtrace.join("\n")
    else
      puts cgi.header()
      puts resbody
    end
  end

  attr_reader :cgi
  def initialize(cgi)
    @cgi = cgi
  end

  def index
    @postings = Posting.find(:all, :conditions => ['closed = 0'], :order
=> 'posted desc', :limit => 10)
    render('index')
  end

  def search
    q = '%' << (cgi['q'] || '') << '%'
    conds = ['closed = 0 AND (description like ? OR requirements like ?
OR title like ?)', q, q, q]
    @postings = Posting.find(:all, :conditions => conds, :order =>
'posted desc')
    render('index')
  end

  def view
    id = cgi['id'].to_i
    @post = Posting.find(id)
    render('view')
  end

  def create
    if cgi['save'] && cgi['save'] != ''
      post = Posting.new
      post.posted = Time.now().to_i
      ['title','company','location','length','contact',
        'description','requirements','hours'].each {|f|
        post[f] = cgi[f]
      }
      ['travel','onsite','terms'].each {|f|
        post[f] = cgi[f].to_i
      }
      post.secret =
Digest::MD5.hexdigest([rand(),Time.now.to_i,$$].join("|"))
      post.closed = 0
      if post.save
        @post = post
      end
    end
    render('create')
  end

  def close
    ## match secret OR id+ADMIN_SECRET

    secret = cgi['secret']
    if secret =~ /^(\d+)\+(.+)$/
      id,admin_secret = secret.split(/\+/)
      post = Posting.find(id.to_i) if admin_secret == ADMIN_SECRET
    else
      post = Posting.find(:first, :conditions => ['secret = ?', secret])
    end

    if post
      post.closed = 1
      post.save
      @post = post
    else
      @error = "Failed to match given secret to your post"
    end

    render('close')
  end

  ## helper methods
  def link_to(name, url_frag)
    return "<a href=\"#{ENV['SCRIPT_NAME']}/#{url_frag}\">#{name}</a>"
  end

  def form_tag(url_frag, meth="POST")
    return "<form method=\"#{meth}\"
action=\"#{ENV['SCRIPT_NAME']}/#{url_frag}\">"
  end

  def select(name, options, selected=nil)
    sel = "<select name=\"#{name}\">"
    options.each_with_index {|o,i|
      sel << "<option value=\"#{i}\" #{(i == selected ? "selected=\"1\"" :
'')}>#{o}</option>"
    }
    sel << "</select>"
    return sel
  end

  def radio_yn(name,val=1)
    val ||= 1
    radio = "Yes <input type=\"radio\" name=\"#{name}\" value=\"1\"
#{(val == 1 ? "checked=\"checked\"": '')}/> / "
    radio << "No <input type=\"radio\" name=\"#{name}\" value=\"0\"
#{(val == 0 ? "checked=\"checked\"" : '')} />"
    return radio
  end

  def textfield(name,val)
    return "<input type=\"text\" name=\"#{name}\" value=\"#{val}\" />"
  end

  def textarea(name,val)
    return "<textarea name=\"#{name}\" rows=\"7\" cols=\"60\">" <<
CGI.escapeHTML(val || '') << "</textarea>"
  end

  def render(name)
    return ERB.new(Actions.template(name),nil,'%<>').result(binding)
  end
end

Actions.dispatch

__END__
index
<%= render('header') %>

<h1>Postings</h1>
<% if @postings.empty? %>
<p>Sorry, no job postings at this time.</p>
<% else %>
<% for post in @postings %>
  <p><%= link_to post.title, "view?id=#{post.id}" %>, <%= post.company
%><br />
  <%= post.location %> (<%= Time.at(0).strftime('%Y-%m-%d') %>)</p>
<% end %>
</table>
<% end %>

<%= render('footer') %>

-=-=-=-=-
create
<%= render('header') %>

<h1>Create new Post</h1>
<% if @post %>
<p>Your post has been successfully added. Please note the following
information, as you will need it
to close you post once it has been filled; <br /><br />
Close code: <%= @post.secret %></p>
<p>Thank you</p>
<% else %>
<% if @error %><p class="error">ERROR: <%= @error %></p><% end %>
<%= form_tag "create" %>
<label for="title">Title</label> <%= textfield "title", cgi['title']
%><br />
<label for="company">Company</label> <%= textfield "company",
cgi['company'] %><br />
<label for="location">Location</label> <%= textfield "location",
cgi['location'] %><br />
<label for="length">Length</label> <%= textfield "length",
cgi['length'] %><br />
<label for="contact">Contact</label> <%= textfield "contact",
cgi['contact'] %><br />
<label for="travel">Travel</label> <%= select 'travel',
Posting::TRAVEL, cgi['travel'] %><br />
<label for="onsite">Onsite</label> <%= radio_yn "onsite", cgi['onsite']
%><br />
<label for="description">Description</label> <%= textarea
"description", cgi['description'] %><br />
<label for="requirements">Requirements</label> <%= textarea
"requirements", cgi['requirements'] %><br />
<label for="terms">Employment Terms</label> <%= select 'terms',
Posting::TERMS, cgi['terms'] %><br />
<label for="hours">Hours</label> <%= textfield "hours", cgi['hours']
%><br />
<input type="submit" name="save" value="create" />
</form>
<% end %>

<%= render('footer') %>

-=-=-=-=-
view
<%= render('header') %>

<% if @post %>
<h1><%= @post.title %></h1>
<table>
<tr><td>Posted</td><td><%=
Time.at(@post.posted.to_i).strftime('%Y-%m-%d') %></td></tr>
<tr><td>Company</td><td><%= @post.company %></td></tr>
<tr><td>Length of employment</td><td><%= @post.length %></td></tr>
<tr><td>Contact info</td><td><%= @post.contact %></td></tr>
<tr><td>Travel</td><td><%= Posting::TRAVEL[@post.travel] %></td></tr>
<tr><td>Onsite</td><td><%= ['No','Yes'][@post.onsite] %></td></tr>
<tr><td>Description</td><td><%=
CGI.escapeHTML(@post.description).gsub(/\n/,"<br />\n") %></td></tr>
<tr><td>Requirements</td><td><%=
CGI.escapeHTML(@post.requirements).gsub(/\n/,"<br />\n") %></td></tr>
<tr><td>Employment terms</td><td><%= Posting::TERMS[@post.terms]
%></td></tr>
<tr><td>Hours</td><td><%= @post.hours %></td></tr>
</table>
<% else %>
<p>ERROR: failed to load given post.</p>
<% end %>

<%= render('footer') %>

-=-=-=-=-
close
<%= render('header') %>

<h1>Close Post</h1>
<% if @post %>
<p>Successfully closed post '<%= @post.title %>' by <%= @post.company
%>.</p>
<% elsif @error %>
<p>ERROR: <%= @error %></p>
<% else %>
<p>ERROR: post not successfully closed, no further description of
error.</p>
<% end %>

<%= render('footer') %>

-=-=-=-=-
header
<html>
<head>
  <title>Simple Job Site</title>
  <style>
  form { display: inline; }
  </style>
</head>
<body>
<%= link_to "Home", "index" %> |
<%= link_to "Create new Post", "create" %> |
<%= form_tag "close" %>
  <input name="secret" type="text" size="16" />
  <input type="submit" value="close" />
</form> |
<%= form_tag "search" %>
  <input name="q" type="text" size="15" /> <input type="submit"
value="search" />
</form><br />

-=-=-=-=-
footer
</body>
</html>

Hey, this is great stuff! Glad to see that someone else had a chance to play around with this one.

Don't feel bad when the summary doesn't mention it tomorrow. My schedule forced me to write it earlier today. :frowning:

Thanks for sharing the solution.

James Edward Gray II

···

On Sep 21, 2005, at 1:36 PM, Paul wrote:

What follows is another basic solution using ActiveRecord only and CGI.
There is minimal CSS styling, but you could add some very easily.

What is interesting about this solution is that it's very small (a
single file and a single table), does not need users and sets itself
up. The solution uses sqlite3 (via gems) and checks if the database
file exists as part of the startup. If it doesn't exist, it creates the
file and the table that it needs.

Listing, searching, viewing details, creating new posts and closing
posts are supported. Each new post generates a 'secret' that the person
posting can then use to close the post with latter, such that users are
not required. Posts can also be 'administratively' closed.

Additionally, although this solution is a single file, all the
interfaces are templated using ERB. Each template is a separate entry
after the __END__ marker, with the first non-whitespace line being the
name and all lines after until the separater line as the file contents.
DRY principles are also in place as the header/footer are seperate
templates and included into each page rather then being repeated.

Paul-
     Wow cool! Great use of ActiveRecord and cgi all in one file. I love it!

Thanks
-Ezra

···

On Sep 21, 2005, at 11:36 AM, Paul wrote:

What follows is another basic solution using ActiveRecord only and CGI.
There is minimal CSS styling, but you could add some very easily.

/me is sad now...

I understand about schedules though; same reason my solution comes so
late. Hopefully I'll have more time for next weeks.