カウンセリングセッションのモデル
さて、ここから、業務要件に従って、予約用の時間枠を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)なんて書けるのは、RailsのActiveSupportの機能ですが、これはなかなかカッコイイですね。
これはいかにも冗長なので メソッド化しましょう。予約枠の生成は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の威力で、かなり簡単にビジネスロジックが書けることと、データベースを使うのにテストが容易なことを実感しました。
本来は、テストはもう少し細かくして、テスト項目ごとにテストメソッドを分けるべきだと思います。また異常系のテストをもっと網羅的にやるべきかもしれませんが、そこは手抜きしました。
日本語化
次に、テスト用の画面を日本語化してみます。
これの付録Fに「日本語を扱うための注意事項」という項目があるので、これを参考に。(言い忘れたというか言うまでもないけど、この本は目茶苦茶参考になってます)
# environment.rbの先頭に追加 $KCODE= "UTF8"
app/controllers/application.rbを変更
require 'login_engine' class ApplicationController < ActionController::Base include LoginEngine helper :user model :user before_filter :set_charset private def set_charset headers["Content-Type"] = "text/html; charset=UTF-8" end end
class InitialSchema < ActiveRecord::Migration def self.up create_table LoginEngine.config(:user_table), :force => true, :options => 'CHARACTER SET utf8' do |t| ....
テスト用のビュー(app/views/test_login/index.rhtml)を以下のように日本語化。
<h1>こんにちは</h1> <% if user? %> <p>こんにちは <%= current_user.lastname %>さん</p> <% else %> <p>こんにちはゲストさん</p> <% end %> <ul> <% unless user? %> <li><%= link_to 'login', :controller => 'user', :action => 'login' %> <% end %> <li><%= link_to "to secret page", :action=>'secret' %> <% if user? %> <li><%= link_to 'edit user information', :controller => 'user', :action => 'edit' %> <% end %> <li><%= link_to 'logout', :controller => 'user', :action => 'logout' %> </ul>
そして、データベースを初期化してから、script/serverで実行し、日本語の名前を登録したら無事に日本語の画面が表示されました。
ユーザオブジェクトの管理方法
ここで、このシステムの基本的なアーキテクチャをまとめると次のようになります。
- LoginEngineの機能で、認証されたユーザに相当するオブジェクトがセッションに格納される
- LoginEngine標準のUserオブジェクトを継承して、それぞれの役割に相当するオブジェクトを生成する
- ユーザオブジェクトにビジネスロジックが実装されているのでそれを呼び出して必要な機能を実行する
ですから、問題は、CounselorとClientというクラスのオブジェクトが適切に生成されているかどうかです。これらのクラスはRailsの単一テーブル継承の機能を使って実装されているので、データベース上の行を生成する時にクラスが決まります。つまり、typeというカラムにクラス名が格納されていて、メモリ上にロードする時は、そのクラスによって生成されることになります。
そして、ユーザオブジェクトのデータベース上の生成は次のようにします。
- カウンセラーは、初期化時にツールで(バッチで)生成する
- クライアントはLoginEngineの機能によって対話的に生成(登録)する
まず、Counselorは次のようにmigrationのスクリプトの中で生成します。
class InitialSchema < ActiveRecord::Migration def self.up ... Counselor.new do |c| c.login = 'co1' c.email = 'tnaka@dc4.so-net.ne.jp' c.verified = 1 c.change_password('abcde') c.save! end end end
カウンセラーは、当面1名で増えたり変更したりすることはめったにないので、これで充分です。もしもの時は、コンソールから対話的にメンテナンスすることになります。
また、カウンセラーのlogin名以外の情報は、一般ユーザと同じようにログインしてから edit の機能で変更できます。
そして、LoginEngineはそのままではsignupの時にUserオブジェクトを生成してしまうので、これを継承してから、Client オブジェクトを生成するように変更します。
$ ruby script/generate conroller Client
ClientControllerは、UserControllerを継承したクラスとして、signupというメソッドを再定義します。修正するのは、 User.new という所を Client.new に書き換えるだけです。
class ClientController < UserController def signup return if generate_blank params[:user].delete('form') # @user = User.new(params[:user]) @user = Client.new(params[:user]) begin .... end end end
このコントローラーに対応するビューは、LoginEngineからコピーして日本語化します。
$ cp vendor/plugins/login_engine/app/views/user/* app/views/client
これに合わせて、app/views/test_login/index.rhtmlを次のように修正します。
<h1>こんにちは</h1> <% if user? %> <p>こんにちは <%= current_user.lastname %>さん</p> <p>あなたは<%= current_user.class %>です</p> <% else %> <p>こんにちはゲストさん</p> <% end %> <ul> <% unless user? %> <li><%= link_to 'login', :controller => 'client', :action => 'login' %> <% end %> <li><%= link_to "to secret page", :action=>'secret' %> <% if user? %> <li><%= link_to 'edit user information', :controller => 'client', :action => 'edit' %> <% end %> <li><%= link_to 'logout', :controller => 'client', :action => 'logout' %> </ul>
そして、データベースを初期化して再実行し、'co1'でログインすると「あなたは Counselor です」と表示されます。サインアップで対話的に作成したユーザでログインすると、「あなたは Client です」と表示されます。
これで、current_user(セッション上のユーザオブジェクト)のクラスによって、対応する処理を行なえばよいことになります。
- current_user が nil ならばゲストの状態
- current_user が Client ならば、一般ユーザ(クライアント)がログインしている状態
- current_user が Counselor ならば、カウンセラー(管理者兼任)がログインしている状態