views:

265

answers:

2

Let's say you're implementing rails app for a snowboard rental store.

A given snowboard can be in one of 3 states:

  1. away for maintenance
  2. available at store X
  3. on loan to customer Y

The company needs to be able to view a rental history for

  • a particular snowboard
  • a particular customer

The rental history needs to include temporal data (e.g. Sally rented snowboard 0123 from Dec. 1, 2009 to Dec. 3 2009).

How would you design your model? Would you have a snowboard table with 4 columns (id, state, customer, store), and copy rows from this table, along with a timestamp, to a snowboard_history table every time the state changes?

Thanks!

(Note: I'm not actually trying to implement a rental store; this was just the simplest analogue I could think of.)

+2  A: 

First I would generate separate models for Snowboard, Customer and Store.

script/generate model Snowboard name:string price:integer ...
script/generate model Customer name:string ...
script/generate model Store name:string ...

(rails automatically generates id and created_at, modified_at dates)

To preserve the history, I wouldn't copy rows/values from those tables, unless it is necessary (for example if you'd like to track the price customer rented it).

Instead, I would create SnowboardEvent model (you could call it SnowboardHistory if you like, but personally it feels strange to make new history) with the similiar properties you described:

  • ev_type (ie. 0 for RETURN, 1 for MAINTENANCE, 2 for RENT...)
  • snowboard_id (not null)
  • customer_id
  • store_id

For example,

script/generate model SnowboardEvent ev_type:integer snowboard_id:integer \
    customer_id:integer store_id:integer

Then I'd set all the relations between SnowboardEvent, Snowboard, Customer and Store. Snowboard could have functions like current_state, current_store implemented as

class Snowboard < ActiveRecord::Base
  has_many :snowboard_events
  validates_presence_of :name

  def initialize(store)
    ev = SnowboardEvent.new(
         {:ev_type => RETURN,
          :store_id => store.id,
          :snowboard_id = id,
          :customer_id => nil})
    ev.save
  end

  def current_state
    ev = snowboard_events.last
    ev.ev_type          
  end

  def current_store
    ev = snowboard_events.last
    if ev.ev_type == RETURN
      return ev.store_id
    end
    nil
  end

  def rent(customer)
    last = snowboard_events.last
    if last.ev_type == RETURN
      ev = SnowboardEvent.new(
           {:ev_type => RENT,
            :snowboard_id => id,
            :customer_id => customer.id
            :store_id => nil })
      ev.save
    end
  end

  def return_to(store)
    last = snowboard_events.last
    if last.ev_type != RETURN
      # Force customer to be same as last one
      ev = SnowboardEvent.new(
           {:ev_type => RETURN,
            :snowboard_id => id,
            :customer_id => last.customer.id
            :store_id => store.id})
      ev.save
    end
  end
end

And Customer would have same has_many :snowboard_events.

Checking the snowboard or customer history, would be just a matter of looping through the records with Snowboard.snowboard_events or Customer.snowboard_events. The "temporal data" would be the created_at property of those events. I don't think using Observer is necessary or related.

NOTE: the above code is not tested and by no means perfect, but just to get the idea :)

joukokar
Thanks for this answer jka! I *almost* set yours as my accepted answer, but EmFi's use of plugins was just so elegant.
splicer
Yes, it seems much more elegant, so might be more what you're looking for. This is probably more basic approach.Now that I think of it, it wasn't a requirement to be able to return boards to different stores and have the price etc. Well... :)
joukokar
+1  A: 

I would use a pair of plugins to get the job done. Which would use four models. Snowboard, Store, User and Audit.

acts_as_state_machine and acts_as_audited

AASM simplifies the state transitions. While auditing creates the history you want.

The code for Store and User is trivial and acts_as_audited will handle the audits model.

class Snowboard < ActiveRecord::Base

  include AASM
  belongs_to :store


  aasm_initial_state :unread
  acts_as_audited :only => :state

  aasm_state :maintenance
  aasm_state :available
  aasm_state :rented

  aasm_event :send_for_repairs do
    transitions :to => :maintenance, :from => [:available]
  end

  aasm_event :return_from_repairs do
    transitions :to => :available, :from => [:maintenance]
  end

  aasm_event :rent_to_customer do
   transitions :to => :rented, :from => [:available]
  end

  aasm_event :returned_by_customer do
    transitions :to => :available, :from => [:rented]
  end
end

class User < ActiveRecord::Base
  has_many :full_history, :class_name => 'Audit', :as => :user,
   :conditions => {:auditable_type => "Snowboard"}
end

Assuming your customer is the current_user during the controller action when state changes that's all you need.

To get a snowboard history:

@snowboard.audits

To get a customer's rental history:

@customer.full_history

You might want to create a helper method to shape a customer's history into something more useful. Maybe something like his:

 def rental_history
    history = []
    outstanding_rentals = {}
    full_history.each do |item|
      id = item.auditable_id
      if rented_at = outstanding_rentals.keys.delete(id)
        history << { 
          :snowboard_id => id, 
          :rental_start => rented_at,
          :rental_end => item.created_at
        }   
      else
        outstanding_rentals[:id] = item.created_at
      end
    end
    history << oustanding_rentals.collect{|key, value| {:snowboard_id => key,  
      :rental_start => value}
  end
end
EmFi
Damn that's elegant! Thanks so much EmFi :D
splicer
Odds are some one has tackled a similar problem and made it a gem/plugin. The only problem is finding out about existing gems/plugins.
EmFi