カウンセリングセッションのモデル

さて、ここから、業務要件に従って、予約用の時間枠をCounselingSessionというモデルクラスとして作成します。

ちょっとネタバラシをすると、ここに書いているのは、完全に作業経過そのままではなくて、若干、予習して問題点をクリアしてから戻ってやり直しながら、それを貼り付けています。この項目については、昨日のうちにRailsのリレーションの機能をいろいろ試しています。

で、まず、モデルクラスの生成。

$ ruby script/generate model CounselingSession
      exists  app/models/
      exists  test/unit/
      exists  test/fixtures/
      create  app/models/counseling_session.rb
      create  test/unit/counseling_session_test.rb
      create  test/fixtures/counseling_sessions.yml
      exists  db/migrate
      create  db/migrate/002_create_counseling_sessions.rb

ここで驚いたのが、migrateのスキーマの雛形を自動的に作ってくれること。

最初に、スキーマファイルは一本にまとめると言いましたが、ここまでやってくれるならそこに乗るしかありません。それで、生成された db/migrate/002_create_counseling_sessions.rbに以下を追加。

class CreateCounselingSessions < ActiveRecord::Migration
  def self.up
    create_table :counseling_sessions, :options => 'CHARACTER SET utf8' do |t|
      t.column "counselor_id", :integer, :null => false
      t.column "client_id", :integer
      t.column "status", :integer, :null => false, :default => 0
      t.column "start", :datetime, :null => false
      t.column "end", :datetime, :null => false
      t.column "place", :string, :limit => 20
      t.column "memo", :string, :limit => 40
    end

    add_index "counseling_sessions", ["counselor_id", "start"], :name => "counseling_sessions_counselor_index"
    add_index "counseling_sessions", ["client_id"], :name => "counseling_sessions_client_index"
  end

  def self.down
    drop_table :counseling_sessions
  end
end

ついでに、データベース再構築をスクリプト化します。

まずは、データベース自体の構築用sqlスクリプト(db/create_database.sql)

drop database if exists coreserve_development ;
create database coreserve_development ;
grant all on coreserve_development.* to coreserve@localhost identified by '*****' ;

drop database if exists coreserve_test ;
create database coreserve_test ;
grant all on coreserve_test.* to coreserve@localhost identified by '*****' ;

drop database if exists coreserve_production ;
create database coreserve_production ;
grant all on coreserve_production.* to coreserve@localhost identified by '******' ;

rakeから実行できるようにRakefileに以下を追加します。

task :create_database do
 sh "mysql -uroot -p < db/create_database.sql"
end

task :re_init_db => [ :create_database, :migrate ]

こうすると、次のコマンドを打つだけで、データベースが再構築されます。

$ rake re_init_db

次は、テーブル間のリレーションの定義。

# app/models/user.rb
class Counselor < User
  has_many :counseling_sessions
  def counselor?
    true
  end
end

class Client < User
  has_many :counseling_sessions
  def counselor?
    false
  end
end

# app/models/counseling_session.rb
class CounselingSession < ActiveRecord::Base
  belongs_to :counselor
  belongs_to :client
end

これで、CounselorとCounselingSessionの間、ClientとCounselingSessionの間にそれぞれ、1対多の関連ができあがります。

これをテストしますが、CounselingSessionのテストの為には、CounselorとClientのオブジェクトが必要になるので、fixtureで作成してみます。

class CounselingSessionTest < Test::Unit::TestCase
  fixture :counselors, :table_name => 'users', :class_name => 'Counselor'
  fixture :clients, :table_name => 'users', :class_name => 'Client'
  fixtures :counseling_sessions

  def setup
    @co1 = Counselor.find_first ['login = ?', 'co1']
    @cl1 = Client.find_first ['login = ?', 'cl1']
    @cl2 = Client.find_first ['login = ?', 'cl2']
  end

  def test_fixture
    assert @co1
    assert_equal Counselor, @co1.class
    assert_equal 'co1', @co1.login

    assert @cl1
    assert_equal Client, @cl1.class
    assert_equal 'cl1', @cl1.login

    assert @cl2
    assert_equal Client, @cl2.class
    assert_equal 'cl2', @cl2.login
  end
end

これを動かしてエラーになるのを確認してから、fixtureを作成します。

# test/fixtures/counselors.yml
co1:
  type: Counselor
  id: 100
  login: co1
  verified: 1

# test/fixtures/clients.yml
cl1:
  type: Client
  id: 201
  login: cl1
  verified: 1

cl2:
  type: Client
  id: 202
  login: cl2
  verified: 1

再度テストを実行し、fixtureが正しく作成されているのを確認してから、CounselingSessionの作成をテストします。

class CounselingSessionTest < Test::Unit::TestCase
  ...
  def test_create_by_hand
    s = CounselingSession.new
    t = Time.now
    s.start = t
    s.end = 60.minute.since(t)
    s.place = 'aaaa'
    s.memo = 'bbbb'
    assert s.save
    @co1.counseling_sessions << s

    # データベースから読み直す
    s = CounselingSession.find_first(['start = ?', t])
    assert_equal('co1', s.counselor.login)
    assert_equal(0, s.status)
    assert_equal(nil, s.client)
    assert_equal(t.to_i, s.start.to_i)
    assert_equal(t.since(60.minute).to_i, s.end.to_i)
    assert_equal('aaaa', s.place)
    assert_equal('bbbb', s.memo)
  end
end

60.minute.since(t)なんて書けるのは、RailsActiveSupportの機能ですが、これはなかなかカッコイイですね。

これはいかにも冗長なので メソッド化しましょう。予約枠の生成はCounselorが行なうので、Counselorのメソッドとして実装しますが、これも先にテストを作ります。

  def test_create
    t = Time.now
    s = @co1.create_session(t, 60)

    # データベースから読み直す
    s = CounselingSession.find_first(['start = ?', t])
    assert_equal('co1', s.counselor.login)
    assert_equal(0, s.status)
    assert_equal(nil, s.client)
    assert_equal(t.to_i, s.start.to_i)
    assert_equal(t.since(60.minute).to_i, s.end.to_i)
    assert_equal(nil, s.place)
    assert_equal(nil, s.memo)

    t = 120.minutes.since(t)
    s = @co1.create_session(t, 30) do |ss|
      ss.place = 'aaaa'
      ss.memo = 'bbbb'
    end

    # データベースから読み直す
    s = CounselingSession.find_first(['start = ?', t])
    assert_equal('co1', s.counselor.login)
    assert_equal(0, s.status)
    assert_equal(nil, s.client)
    assert_equal(t.to_i, s.start.to_i)
    assert_equal(t.since(30.minute).to_i, s.end.to_i)
    assert_equal('aaaa', s.place)
    assert_equal('bbbb', s.memo)
  end

create_sessionのパラメータとしては、開始時刻と時間(分)が必須で、それ以外の項目の設定が必要な時はブロックの中で設定することにしました。

では、これを実装します。

class Counselor < User
  has_many :counseling_sessions
  def counselor?
    true
  end

  def create_session(start, length, &block)
    s = CounselingSession.new
    s.start = start
    s.end = length.minutes.since(start)
    block.call(s) if block_given?
    s.save!
    counseling_sessions << s
    s
  end
end

次に、ダブルブッキングがエラーになるようにしますが、これもテストから実装します。

  def test_double_booking
    t = Time.now
    s = @co1.create_session(t, 60)
    cnt =  CounselingSession.count

    assert_raise(RuntimeError) do
      @co1.create_session(t+1, 60)
    end
    assert_equal(cnt, CounselingSession.count)

    assert_raise(RuntimeError) do
      @co1.create_session(30.minutes.since(t), 60)
    end
    assert_equal(cnt, CounselingSession.count)

  end

では、チェック機能を実装します。

class Counselor < User
  has_many :counseling_sessions
  def counselor?
    true
  end

  def create_session(start, length, &block)
    s = CounselingSession.new
    s.start = start
    s.end = length.minutes.since(start)
    check_double_booking(s)
    block.call(s) if block_given?
    s.save!
    counseling_sessions << s
    s
  end

private
  def check_double_booking(s)
    ss = CounselingSession.find_first(['counselor_id = ? and start < ? and end > ?', self.id, s.start, s.start])
    raise "double booking #{s.start}" if ss
    ss = CounselingSession.find_first(['counselor_id = ? and start < ? and end > ?', self.id, s.end, s.end])
    raise "double booking #{s.end}" if ss
  end
end

実装しながら、「カウンセラーが違えば時間が重なってもいい」ということに気がついたので、そのテストも追加しました。

  def test_double_booking_with_two_counselor
    t = Time.now
    s = @co1.create_session(t, 60)
    cnt =  CounselingSession.count

    assert_raise(RuntimeError) do
      @co1.create_session(t+1, 60)
    end
    assert_equal(cnt, CounselingSession.count)

    co2 = Counselor.create(:login => "co2", :email => "co2@a.com")
    co2.create_session(t+1, 60)
    assert_equal(cnt+1, CounselingSession.count)
  end

次に予約の操作をClientのメソッドとして実装します。同じくテストから先に実装。

  def test_reserve
    t = Time.now
    s = @co1.create_session(t, 60)
    @cl1.reserve(s)

    # データベースから読み直す
    s = CounselingSession.find_first(['start = ?', t])
    assert_equal('co1', s.counselor.login)
    assert_equal(1, s.status)
    assert_equal(@cl1.login, s.client.login)
    assert_equal(t.to_i, s.start.to_i)
    assert_equal(t.since(60.minute).to_i, s.end.to_i)
  end

メソッド本体の実装。

class Client < User
  has_many :counseling_sessions
  def counselor?
    false
  end

  def reserve(s)
    raise "already reserved" if s.status == :reserved
    raise "can't change status" if s.status != :free
    s.status = :reserved
    Client.transaction do
      s.save!
      self.counseling_sessions << s
    end
  end
end

ここで、s.status = :reserved のような書き方をしたいので、テーブル上のカラムはstatus_codeという名前にして、アプリケーションからは、シンボルで使えるように変更しました。

class CounselingSession < ActiveRecord::Base
  belongs_to :counselor
  belongs_to :client

  StatusToStatusCode = {
    :free => 0,
    :reserved => 1,
    :done => 2,
    :not_done => 3
  }

  StatusCodeToStatus = {
    0 => :free,
    1 => :reserved,
    2 => :done,
    3 => :not_done
  }

  def status
    StatusCodeToStatus[self.status_code]
  end

  def status=(s)
    code = StatusToStatusCode[s]
    raise "illeagal status #{s}" unless code
    self.status_code = code
  end
end
  def test_reserve_error
    t = Time.now
    s = @co1.create_session(t, 60)
    @cl1.reserve(s)

    assert_raise(RuntimeError) do
      @cl1.reserve(s)
    end

    s.status = :done
    s.save!
    assert_raise(RuntimeError) do
      @cl1.reserve(s)
    end

    s.status = :not_done
    s.save!
    assert_raise(RuntimeError) do
      @cl1.reserve(s)
    end
  end

これで、テストがOKになったら、予約できないステータスでのエラーのテストも入れます。

  def test_reserve_error
    t = Time.now
    s = @co1.create_session(t, 60)
    @cl1.reserve(s)

    assert_raise(RuntimeError) do
      @cl1.reserve(s)
    end

    s.status = :done
    s.save!
    assert_raise(RuntimeError) do
      @cl1.reserve(s)
    end

    s.status = :not_done
    s.save!
    assert_raise(RuntimeError) do
      @cl1.reserve(s)
    end
  end

次はキャンセルです。正常系テスト、実装、異常系テストの順に作っていきます。

業務要件で、開始時刻を過ぎたらキャンセルはできないことになっています。それをチェックする為に、現在時刻をパラメータとして渡します。パラメータ化することによって、テストが楽になります。

  def test_cancel
    t = Time.now
    s = @co1.create_session(t, 60)
    @cl1.reserve(s)
    @cl1.cancel(s, 1.ago(t))

    # データベースから読み直す
    s = CounselingSession.find_first(['start = ?', t])
    assert_equal('co1', s.counselor.login)
    assert_equal(:free, s.status)
    assert_equal(nil, s.client)
    assert_equal(false, @cl1.counseling_sessions.include?(s))
  end
  def cancel(s, now=Time.now)
    raise "not reserved" unless s.status == :reserved
    raise "not my resevation" unless s.client == self
    raise "already started" if s.start < now
    s.status = :free
    Client.transaction do
      s.save!
      self.counseling_sessions.delete(s)
    end
  end
  def test_cancel_error
    t = Time.now
    s = @co1.create_session(t, 60)

    # 予約前にキャンセル
    assert_raise(RuntimeError) do
      @cl1.cancel(s)
    end

    @cl1.reserve(s)
    @cl1.cancel(s, 1.ago(t))

    # キャンセル後に再度キャンセル
    assert_raise(RuntimeError) do
      @cl1.cancel(s)
    end

    # 別人がキャンセル
    @cl1.reserve(s)
    assert_raise(RuntimeError) do
      @cl2.cancel(s)
    end

    # データベースから読み直す
    s = CounselingSession.find_first(['start = ?', t])
    assert_equal('co1', s.counselor.login)
    assert_equal(:reserved, s.status)
    assert_equal(@cl1, s.client)
    assert_equal(true, @cl1.counseling_sessions.include?(s))
  end

次に表示関係の機能をモデルのメソッドにして行きます。まず、表示用のデータをfixtureで追加します。

free1:
  id: 10001
  couselor_id: 100
  status_code: 0
  start: 2006-05-30 10:00
  end: 2006-05-30 11:00
  memo: free1

free2:
  id: 10002
  couselor_id: 100
  status_code: 0
  start: 2006-05-30 13:00
  end: 2006-05-30 14:00
  memo: free2

reserved1:
  id: 10003
  couselor_id: 100
  client_id: 201
  status_code: 1
  start: 2006-06-01 13:00
  end: 2006-06-01 14:00
  memo: reserved1

done1:
  id: 10004
  couselor_id: 100
  client_id: 201
  status_code: 2
  start: 2006-05-31 13:00
  end: 2006-05-31 14:00
  memo: done1

not_done1:
  id: 10005
  couselor_id: 100
  status_code: 3
  start: 2006-05-20 13:00
  end: 2006-05-20 14:00
  memo: not_done1

このfixtureのデータをそのままステータス別に取り出すテスト。カウンセラーとクライアントでそれぞれ自分に関係するセッションだけ取り出せるかテストします。

  def test_list_for_counselor
    l = @co1.list_sessions(:free)
    assert_equal(2, l.size)
    assert_equal('free2', l[0].memo)
    assert_equal('free1', l[1].memo)

    l = @co1.list_sessions(:reserved)
    assert_equal(1, l.size)
    assert_equal('reserved1', l[0].memo)
    assert_equal(@cl1, l[0].client)

    l = @co1.list_sessions(:done)
    assert_equal(1, l.size)
    assert_equal('done1', l[0].memo)
    assert_equal(@cl1, l[0].client)

    l = @co1.list_sessions(:not_done)
    assert_equal(1, l.size)
    assert_equal('not_done1', l[0].memo)
    assert_equal(nil, l[0].client)
  end

  def test_list_for_client
    l = @cl1.list_sessions(:reserved)
    assert_equal(1, l.size)
    assert_equal('reserved1', l[0].memo)
    assert_equal(@cl1, l[0].client)

    l = @cl1.list_sessions(:all)
    assert_equal(2, l.size)
    assert_equal(@cl1, l[0].client)
    assert_equal(@cl1, l[1].client)
    assert_equal('done1', l[1].memo)
    assert_equal('reserved1', l[0].memo)
  end

実装は、ベースクラスのUserに定義すると、一つのコードでCounselor用とClient用両方をサポートします。

class User < ActiveRecord::Base
  include LoginEngine::AuthenticatedUser

  def list_sessions(status)
    if status == :all
      counseling_sessions.find(:all, :order =>' start desc')
    else
      counseling_sessions.find(:all,
                               :conditions => ['status_code = ?', CounselingSession::StatusToStatusCode[status]],
                               :order=>'start desc')
    end
  end
end

実装は一つですが、Counselorオブジェクトに対してこれを呼ぶと、counselor_idがこれと一致するもの、Clientオブジェクトに対して呼ぶと、client_idが一致するものを呼び出します。counseling_sessionsというメソッドが、子クラスでそれぞれ定義されていて、別の動きをするわけです。

ActiveRecoredの威力を見せつけられた感じです。

最後に、空き時間の全件出力です。

  def test_list_all_free
    l = CounselingSession.list_free
    assert_equal(2, l.size)
    assert_equal(:free, l[0].status)
    assert_equal(:free, l[1].status)
    assert_equal(nil, l[0].client)
    assert_equal(nil, l[1].client)

    # もう一人カウンセラーを追加して、枠を作ってみる
    co2 = Counselor.create(:login=>'co2', :email=>'co2@a.com')
    t = Time.now
    co2.create_session(t, 60)
    l = CounselingSession.list_free
    assert_equal(3, l.size)
    l.each do |s|
      assert_equal(:free, s.status)
      assert_equal(nil, s.client)
    end

    assert_equal(co2, l[0].counselor)
    assert_equal(@co1, l[1].counselor)
    assert_equal(@co1, l[2].counselor)
  end
class CounselingSession < ActiveRecord::Base
  ....
  def self.list_free
    self.find(:all,
              :conditions => ['status_code = ?', CounselingSession::StatusToStatusCode[:free]],
              :order=>'start desc')
  end
  ...
end


ここはだいぶ長くなりましたが、モデル(ビジネスロジック)は、これでほぼ全部完成したと思います。

ActiveRecoredの威力で、かなり簡単にビジネスロジックが書けることと、データベースを使うのにテストが容易なことを実感しました。

本来は、テストはもう少し細かくして、テスト項目ごとにテストメソッドを分けるべきだと思います。また異常系のテストをもっと網羅的にやるべきかもしれませんが、そこは手抜きしました。