1 はじめに

1.1 Ruby on Rails とは

ここでは詳しくは説明しません。 本家のHPはここです。 検索エンジンで探せばたくさん情報が見つかるでしょう。 「Rubyist Magazine の RubyOnRails を使ってみる」 もさんこうになるでしょう 第 1 回

1.2 このチュートリアルについて

RailsによるアジャイルWebアプリケーション開発(オーム社: ISBN4-274-06640-1) の盗作です。 ただ、そこで取り上げられている例が「商品販売Webサーバーの作り方」なので、 どうせつくるなら真似して自分の役に立つものを! ということで、 かねてからの懸案であった論文管理システムをつくることにしました。

このアプリケーションは、あくまでチュートリアル用に作ったものです。 個人使用ではそれなりに動くかも知れません (問題が潜んでいる可能性はおおありですが)。 ただし、セキュリティーなどはあまり考慮していませんので、 このサーバを全世界に公開するなんてことは決してしないでください。

2 論文管理アプリケーション

最近、論文は雑誌をコピーなんてことをせずに、 電子ジャーナルなるものから、 PDFファイルをダウンロードなんてこと増えましたよね。 その量が結構増えてくると、 お目当てのファイルを探すのに一苦労しますよね。 そこで、Ruby on Rails を勉強するついでに、 これ幸いと、論文管理アプリケーションをつくってみまししょう。

2.1 インクリメンタル開発

RailsによるアジャイルWebアプリケーション開発 にのっとって、 ここでも、開発はインクリメンタル(漸進的)開発手法を用います。 前もってすべての仕様を決定するのではなく、 とにかく動くものをつくって実行し、 そしてそこで気づいた問題点をもとに改良し、 また実行、・・・を繰り返していきます。

2.2 基本仕様

まず論文管理アプリケーションの大まかな仕様を決めます。

2.2.1 ユースケース

このアプリケーションのユースケースは単純で、 2つの役割を定義します。 それは、管理検索です。

管理では、 論文の登録や情報の修正などの管理を行います。

一方、検索では、 今必要な論文を探し出すために検索を行います。

2.2.2 ページフロー

このアプリケーションの流れは、 あまりに簡単ですから、 Dave*1 のように紙に書き出す必要すらありません。

管理のページフローは、 ログインすると、メニューが表示され、 論文情報の作成または表示、 雑誌情報の作成または表示、 キーワードの登録または表示、 を実行できます。 それぞれの情報を表示すると、 必要に応じて情報を編集できる他、その論文情報を削除することもできます。

検索のページフローは極めて単純です。 検索ページでキーワードを入力し、 検索結果を得ます。

このアプリケーションは、非常に単純で、 持っている機能は少ないですが、 ユーザーが不満に思う点は、 その都度修正していけばよいのです。

2.2.3 データ

このアプリケーションが持つデータとして必要だと思われるものを次にあげます。

論文のキーワードは複数である可能性があり、 データベーステーブルを別ける必要があります。

3 論文情報管理

3.1 Railsアプリケーションの作成

ここではすでに Rails がインストールされているものと仮定します。 インストールがお済みでない方は、 他のサイトを参考にしてインストールしてください。

今回は論文管理アプリケーションなので、 名前は papers とします。 Railsアプリケーションを新規に作成するには、 次のコマンドを入力します。

> rails papers

コマンドの実行が終わると、 papers という新しいディレクトリができているはずです。

> cd paper
> ls
README    app/         config/  doc/  log/     script/  tmp/
Rakefile  components/  db/      lib/  public/  test/    vendor/

3.2 データベースの作成

ここでは、MySQL をデータベースサーバーとして使用します。 他のデータベースサーバーも使用できますが、 その際は、 該当個所を適宜お使いのデータベースサーバーの操作法に読み替えて下さい。

まず、データベースを3つ用意します。

> mysql -u root -p
Enter password: *******
Welcome to the MySQL monitor.  Commands end with ; or \g.
                  :
mysql> create database paper_development;
mysql> create database paper_test;
mysql> create database paper_production;
mysql> grant all on paper_development.* to 'yourname'@'localhost';
mysql> grant all on paper_test.* to 'yourname'@'localhost';
mysql> grant all on paper_production.* to 'adminname'@'localhost' identified by 'password';
mysql> exit

3.3 Papers テーブルの作成

まず、論文用のデータベーステーブルを作成します。 雑誌名は、雑誌用データベーステーブルを作成し、 それを参照するようにするので、 いまはとりあえず除いておきます。

MySQLで papers テーブルを作成するためのデータ定義言語(DLL)は、 次のとおりです。

drop table if exists papers;
create table papers (
  id		int		not null auto_increment,
  authors	varchar(100)	not null,
  title		varchar(200)	not null,
  year		int		not null,
  file_url	varchar(200)	not null,
  primary key(id)
);

第2章 の基本仕様で考えたときには無かった id という列が追加されています。 id 列は、テーブル内の行一つひとつに一意なキーを与えて、 たのテーブルから参照できるようにするためのものです。 また、Railsでは、デフォルトで、 どのテーブルにも id という名前の整数列が 主キーとして含まれていることを想定しています。 Dave にならい、この papers テーブルの DDL をフラットファイルとして、 db サブディレクトリに create.sql という名前で保存します。 以下のコマンドで、paper_development データベースに papers テーブルを作成します。

> mysql paper_development < db/create.sql

3.4 アプリケーションの設定

データベースに接続するための情報は、 config/database.yml に書いておきます。 先ほどデータベースをつくった際に設定したユーザー名、パスワードを、 書いておきましょう。

development:
  adapter: mysql
  database: paper_development
  username: ""
  password:
  host: localhost

test:
  adapter: mysql
  database: paper_test
  username: ""
  password:
  host: localhost

production:
  adapter: mysql
  database: paper_production
  username: adminname
  password: password
  host: localhost

username フィールドを空文字列 "" にすると、 実行ユーザーの名前または root が使用されます。

3.5 論文情報管理アプリケーションの作成

ここまでで、基本的な準備は終わりました。 次は、論文情報管理アプリケーションを作成します。

> ruby script/generate scaffold Paper Paper

たくさん文字が流れて行きましたね。 一つめの Paper パラメターはモデルの名前で、 二つめの Paper パラメターはコントローラーの名前です。 モデルの名前は、データベーステーブルの名前の単数型+先頭が大文字です。 コントローラー名は好きにつけてかまいません。 これで、基本的な論文情報管理アプリケーションが作成されました。 なんとこれだけで、このアプリケーションはそれなりに動作します。 早速、実感してみましょう。 最初に、Rails に付属する WEBrick ベースの Web サーバをローカルで起動します。

> ruby script/server
=> Booting WEBrick...
=> Rails application started on http://0.0.0.0:3000
=> Ctrl-C to shutdown server; call with --help for options
[2006-07-21 16:59:01] INFO  WEBrick 1.3.1
[2006-07-21 16:59:01] INFO  ruby 1.8.4 (2005-12-24) [i386-linux]
[2006-07-21 16:59:01] INFO  WEBrick::HTTPServer#start: pid=5170 port=3000

このコマンドを実行すると、Web サーバがローカルホストのポート3000で起動します。 このサーバにアクセスしてみましょう。 ブラウザーで、http://localhost:3000/paper にアクセスしてみましょう (この paper は上で作成したコントローラーの名前です)。

paper_list-0

論文情報のリストを表示する画面が表示されましたが、 まだ一つも登録していませんので、 つまらない画面ですね。 では、論文情報を登録してみましょう。 New paper のリンクをクリックしてください。 簡潔な登録フォームが表示されます。

paper_new-1

早速、情報を入力し、登録してみましょう。 今度は、リストに今登録した論文が表示されていると思います。

paper_list-1

リストのそれぞれ(まだ一つだけですが)のアイテムには、 詳細表示(Show)、編集(Edit)、削除(Destroy)するためのリンクがついています。 それぞれクリックして、 その機能を試してみましょう。

データベースの準備と、ちょっとした設定と、 わずか一つのコマンドの実行だけでここまでできるものができるんです。 Rails 恐るべしですね。 データベースの準備がめんどくさかった、なんていわないでくださいね。 それは Rails のせいではありませんから。

3.6 列の追加

しばし、論文の追加や編集、削除を楽しんだことだと思います。 ここで、ひとつ気が付きました。 論文情報に 重要度 をつけておけば、 あとから検索したときに、大事な論文を見つけやすいのでは!! さっそくデータベーステーブルに列を追加しましょう。 db/create.sql を編集して、priority 列を追加してみましょう。

drop table if exists papers;
create table papers (
  id		int		not null auto_increment,
  authors	varchar(100)	not null,
  title		varchar(200)	not null,
  year		int		not null,
  file_url	varchar(200)	not null,
  priority	int		not null,
  primary key(id)
);

このファイルを最初に作成したときに、 drop table コマンドを一番上に書いておきました。 こうすることで、新しくテーブルをつくり直すことができます。

> mysql paper_development < db/create.sql

先ほど登録したデータはすべて消えます。 まだ開発段階ですから、 まさか、せっせと苦労して大量の論文情報を登録してしまった、 なんてことはありませんよね。 そんな場合は、何らかの形でデータベースを移行しましょう。 mysqldumpなどをつかってダンプし、それを再読み込みしてもいいでしょう。

さて、データベーステーブルを更新したので、 先ほどつくった scaffold のコードは古くなってしまいました。 再び scaffold をつくり直しましょう。 途中で、ファイルを上書きするかどうか尋ねられますが、 コードには変更を加えていないので、 安心して上書きしてもらいましょう。 「a」と入力するとすべてのファイルが上書きされます。

> ruby script/generate scaffold Paper Paper
                  :
overwrite app/views/paper/_form.rhtml? [Ynaq] a
                  :

ブラウザの表示を更新し、 New paper のページに移動すると、 重要度の項目が追加されているのが分かるでしょう。

paper_new-priority

驚くべきことに、Web サーバを立ち上げ直さずに、 変更が反映されています。 Rails は、開発モードでは、 ファイルの更新を検出して、自動で再読み込みするので、 このようなことが可能なのです。 これは、開発を迅速に行うことができる一つの重要なポイントです。

3.7 入力データの検証

今のままでは、入力フォームに記入し忘れた項目があっても、 そのまま受け付けられてしまい、データベースに登録されてしまいます。 印刷年が-10000年だったり未来だったりしてもお構いなしです。 データベースに登録する前に検証が必要です。

このような検証は、 データベースとのやり取りを行うモデルにやってもらいます。 そうすると、フォームの入力に限らず、 アプリケーションのどの部分からの操作であっても、 データベースに書き込む前にチェックできます。

では、モデルクラス (app/models/paper.rb 内) のソースコードを見てみましょう。

class Paper < ActiveRecord::Base
end

データーベースとのやり取りの機能は、 親クラスである ActiveRecord::Base がすべてもっているので、 Paper クラスにはその機能が自動的に引き継がれています。

ここに検証のためのコードを追加しましょう。 Rails には、検証のためのメソッドが標準で用意されていますので、 それを使いましょう。

まず、フィールドに何らかのデータが含まれていることを検証しましょう。

class Paper < ActiveRecord::Base
  validates_presence_of :authors, :title, :year, :file_url, :priority
end

validates_presence_of() メソッドは、指定されたフィールドが存在し、 かつその内容が空でないことを確認します。 フィールドが未入力のまま Create ボタンをクリックすると、 フォームの一番上にエラーを説明するメッセージが表示され、 エラーのあったフィールドが強調されます。

次に、有効な数値データかどうかの検証です。 validates_numericality_of() メソッドを使います。

validates_numericality_of :year, :priority

year フィールドか priority フィールドに数値ではないデータを入力してみてください。 適切なエラーメッセージが表示されます。

入力されたデータが正しいフォーマットかどうかも検査できます。 ファイルはPDFファイルであるとし、 ファイルのURLは .pdf で終わっていなければならないとします。 validates_format_of() メソッドを使いましょう。

validates_format_of :file_url, :with => %r{\.pdf$}i, :message => "must be PDF file"

その他にも、テーブル内で名前の重複が許されない場合の検証には validates_uniqueness_of() メソッドが使えます。

Rails がメソッドを用意していない検証も簡単にできます。 validate() というメソッドをモデルクラスに追加します。 Rails は、データを保存する前に自動的にこのメソッドを呼び出すので、 このメソッドを使ってフィールドデータの妥当性をチェックできます。 このメソッドがモデルのコンテキストの外部から呼び出されることは 避けなければならないので、protected メソッドにします。 では validate メソッドを使って、 印刷年および、重要度の検証を行いましょう。 印刷年は1500年から今年までの間であるとしましょう (もっとも古い論文が何年のものであるかはしりませんが)。 重要度は3段階だとし、1,2,3のどれかだとしましょう。

protected
def validate
  unless year.nil?
    if year < 1500 || year > Date.today.year
      errors.add(:year, "must be >= 1500 and <= this year")
    end
  end
  unless priority.nil?
    unless [1,2,3].include? priority
      errors.add(:priority, "must be 1, 2, or 3")
    end
  end
end

何も入力せずに Create ボタンをクリックすると、 適切なメッセージを表示してくれてますね。

paper_new-validation

3.8 見栄えをもっと美しく

基本的な機能はそろいましたが、 見た目はお世辞にもよいとはいえません。 そこで見栄えをよくしましょう。

まず、登録入力フォームからとりかかりましょう。 重要度は 1,2,3 のどれかでないといけないのに、 それを手で入力するのはあまり美しくありませんね。 選べるようにしましょう。 見た目を司っているのはビューです。 scaffold ジェネレータによって app/views/paper ディレクトリ以下に ERb用 のファイルが作成されています。 これを修正します。 新規登録用のビューファイルは new.rhtml です。 中身は以下のようになっています。

<h1>New paper</h1>

<%= start_form_tag :action => 'create' %>
  <%= render :partial => 'form' %>
  <%= submit_tag "Create" %>
<%= end_form_tag %>

<%= link_to 'List', :action => 'list' %>

実際の入力フォームは _form.rhtml に書かれており、 ここではそれを呼び出しています。 どうしてそんなことをしているのでしょうか。 それは リストから編集用のページを開けば分かります。 新規登録用のフォームとほとんど同じですね。 編集用のビューファイルは edit.rhtml です。 見てみると、ここでも form.rhtml が呼び出されているのが分かります。 重複を避けるために、共通部分を切り出して、 別ファイルにしてあるのです。 こうすることで、一カ所の変更で、 新規登録と編集の両方を同時に変更することができます。

では、早速 _form.rhtml を見てみましょう。 該当の部分は

<p><label for="paper_priority">Priority</label><br/> <%= text_field 'paper', 'priority' %></p>

です。 <%= %> というのは ERb の文法で、中の ruby コードを実行した結果を そこに埋め込むというものです。 text_field() メソッドは、Rails が標準で用意している フィールドフィールドをつくるメソッドです。 これをセレクトメニューフィールドに変更しましょう。

<p><label for="paper_priority">Priority</label><br/>
<%= select 'paper', 'priority', Paper::PRIORITIES, :include_blank=>true %></p>

選択する際に、1,2,3 という数字ではなく、 important などの分かりやすい単語の方がより分かりやすいので、 その選択肢を Paper モデルクラスの定数配列 Paper::PRIORITIES として定義されていると想定しています。 この配列の各要素は、選択リストに表示される文字列と、 その選択肢が選択された場合にデータベースに格納される値です。 忘れないうちに、 この選択肢の配列の定義を app/models/paper.rb モデル内に記述しておきましょう

class Paper < ActiveRecord::Base
  PRIORITIES = [["important",1],["normal",2],["others",3]].freeze

freeze によって、この配列の値を変更することを禁止しています。

ではここで、ブラウザで動作を確認しておきましょう。 ちゃんと重要度が選択リストになっていますね。

paper_new_priority-select

では次は、リストの見栄えをよくしましょう。 リストの見た目を変更するためには、 app/views/paper/list.rhtml ファイルを変更します。 デフォルトのコードの動的処理能力は見事で、 新たに追加された列も自動的に表示されますが、 見た目はあまり美しくありません。 ここは思いきって大幅に変更しましょう。

<h1>Listing papers</h1>
<table cellpadding="5" cellspacing="0">
<%
odd_or_even = 0
for paper in @papers
  odd_or_even = 1 - odd_or_even
%>
  <tr valign="top" class="ListLine<%= odd_or_even %>">
    <td width="60%">
      <span class="ListName"><%= h(paper.authors) %> (<%= h(paper.year) %>)</span><br />
      <%= h(truncate(paper.title, 80)) %><br/>
    </td>
    <td>
      <a href="<%= h(paper.file_url) %>">PDF file</a><br/>
      <%= h(Paper::PRIORITIES[paper.priority-1][0]) %>
    </td>
    <td class="ListActions">
      <%= link_to 'Show', :action => 'show', :id => paper %><br />
      <%= link_to 'Edit', :action => 'edit', :id => paper %><br />
      <%= link_to 'Destroy', { :action => 'destroy', :id => paper }, :confirm => 'Are you sure?', :post => true %>
    </td>
  </tr>
<% end %>
</table>
<br/>
<hr>
<% if @paper_pages.length > 1 %>
<p>
page: <%= pagination_links @paper_pages %>
</p>
<% end %>
<p>
<%= link_to 'New paper', :action => 'new' %>
</p>

テーブルの各行の表示をCSSクラス名で指定し、一行おきに異なる背景色で表示します。 scaffold ジェネレーターで生成されるすべてのアプリケーションは、 public/stylesheets ディレクトリにある scaffold.css というスタイルシートを使います。 そこでこのファイルに以下を追加します。

.ListAuthors {
  color: #244;
  font-weight: bold;
  font-size: larger;
}
.ListActions {
  font-size: x-small;
  text-align: right;
  padding-left: 1em;
}
.ListLine0 {
  background: #e0f8f8;
}
.ListLine1 {
  background: #e0b0f8;
}

表示される順番は、著者名、印刷年、タイトルでソートしましょう。 Paper コントローラーの list アクションを編集します

def list
  @paper_pages, @papers =
    paginate :papers, :per_page => 10, :order => 'authors, year, title'
end

ついでに詳細表示ページもちょっとだけ修正しておきましょう。 app/views/paper/show.rhtml を編集します。 重要度は数字ではなく文字列に、 ファイルのURLはリンクにしましょう。

<% for column in Paper.content_columns %>
<p>
  <b><%= column.human_name %>:</b>
<%
case column.name
when "file_url"
%>
  <a href="<%= h @paper.file_url %>"><%= h @paper.file_url %></a>
<%
when "priority"
%>
  <%= Paper::PRIORITIES[@paper.priority-1][0] %>
<%
else
%>
  <%=h @paper.send(column.name) %>
<% end %>
</p>
<% end %>

<%= link_to 'Edit', :action => 'edit', :id => @paper %> |
<%= link_to 'List', :action => 'list' %>

早速、ブラウザでその美しさを体験しましょう。 先ほどよりも、格段に見栄えがよくなったでしょ。

paper_list_new

paper_show_new

ちなみに、 ファイルの置き場ですが、 public ディレクトリの下に pdfs ディレクトリを作り、 その下にPDFファイルを置いておくと、 http://localhost:3000/pdfs/filename.pdf という URL でアクセスできます。

4 雑誌情報管理

基本的な論文情報の管理機能はできました。 さて、前回棚上げにしていた雑誌情報を追加することにしましょう。 論文情報のところで毎回名前を入力するようにしてもいいのですが、 重複するデータはべつテーブルに分ける(正規化)、 というデータベースの基本にそって、 別テーブルで雑誌情報を管理しましょう。

4.1 Journal テーブルの作成

では、雑誌情報のデータベーステーブルを作成します。 db/create.sql に以下を追加しましょう。

drop table if exists journals;
create table journals (
  id            int     not null auto_increment,
  name          varchar(100)    not null,
  abbreviation  varchar(10)     not null,
  primary key(id)
);

journals テーブルができたので、 papers テーブルに journals テーブルの id への外部キー参照リンクをつくりましょう。

drop table if exists papers;
create table papers (
  id            int             not null auto_increment,
  authors       varchar(100)    not null,
  title         varchar(200)    not null,
  year          int             not null,
  journal_id    int             not null,
  file_url      varchar(200)    not null,
  priority      int             not null,
  constraint fk_journal foreign key (journal_id) references journals(id),
  primary key(id)
);

では、実際にデータベースにこのテーブルをつくりましょう。

> mysql paper_development < db/create.sql

これでデータベース側の変更ができました。

4.2 雑誌情報管理アプリケーションの作成

続いて、雑誌情報管理アプリケーションを作成しましょう。 使うのは、scaffold ジェネレーターですね。 ここで注意するのは、 先ほど折角編集した scaffold.css を上書きされないように、 「n」と返事することです。

> ruby script/generate scaffold Journal Journal
                   :
overwrite public/stylesheets/scaffold.css? [Ynaq] n

先ほどと同じように、入力データの検証コードを追加しておきましょう。 app/models/journal.rb を編集します。

class Journal < ActiveRecord::Base
  validates_presence_of :name, :abbreviation
  validates_uniqueness_of :name, :abbreviation
end

これで、雑誌情報の管理機能を追加できました。 早速 Journal コントローラーにアクセスしてみましょう。 ブラウザーで、http://localhost:3000/journal にアクセスです。 では、いくつか雑誌を登録しましょう。

journal_list_0

journal_new_1

リストの見た目も同じように変えておきましょうね。 app/views/journal/list.rhtml を編集します。

<h1>Listing journals</h1>
<table cellpadding="5" cellspacing="0">
<%
odd_or_even = 0
for journal in @journals
  odd_or_even = 1 - odd_or_even
%>
  <tr valign="top" class="ListLine<%= odd_or_even %>">
    <td width="60%">
      <span class="ListName"><%= h(journal.name) %></span><br />
    </td>
    <td>
      <span class="ListName">(<%= h(journal.abbreviation) %>)</span><br />
    </td>
    <td class="ListActions">
      <%= link_to 'Edit', :action => 'edit', :id => journal %><br />
      <%= link_to 'Destroy', { :action => 'destroy', :id => journal }, :confirm => 'Are you sure?', :post => true %>
    </td>
  </tr>
<% end %>
</table>
<br/>
<hr>
<% if @journal_pages.length > 1 %>
<p>
page: <%= pagination_links @journal_pages %>
</p>
<% end %>
<p>
<%= link_to 'New journal', :action => 'new' %>
</p>

名前の順で表示されるように Journal コントローラの list アクションを修正しましょう。 (app/controllers/journal_controller.rb)。

def list
  @journal_pages, @journals =
    paginate :journals, :per_page => 10, :order => 'name'
end

journal_list_1

あと、これはどうでもよいのですが、 雑誌情報は名前と省略名だけですよね。 詳細情報ページは必要ありませんね。リストで十分です。 app/controllers/journal.rb 内の Journal コントローラーから関連個所を編集しましょう。 詳細情報に相当するキーワードは 'show' です。 ファイル内を探すと2個所ありますね。

def show
  @journal = Journal.find(params[:id])
end
 :
def update
  @journal = Journal.find(params[:id])
  if @journal.update_attributes(params[:journal])
    flash[:notice] = 'Journal was successfully updated.'
    redirect_to :action => 'show', :id => @journal
  else
    render :action => 'edit'
  end
end

show メソッドは躊躇無く消してしまいましょう。 update メソッド は、雑誌情報を編集した際に呼ばれます。 このソースを見ると、 アップデートが成功した場合は詳細情報ページを表示することになっています。 リストを表示するように変えましょう

redirect_to :action => 'list', :id => @journal

また、ビューから詳細表示ページを削除しておきましょう。

> rm app/views/journal/show.rhtml

これで完璧です。 詳細表示ページを残したい人は残しておいてまったく問題ありません。 無害ですから。

4.3 雑誌情報を論文管理アプリケーションに組み込む

雑誌情報の管理アプリケーションができたので、 次は、論文情報に追加しましょう。 外部参照リンクだからといって、特別必要なことはほとんどありません。

まずは、Paper モデルに Journal を参照していることを教えてあげる必要があります。 Rails が用意している belongs_to() メソッドを使用します。 app/models/paper.rb 内の Paper モデルクラスに以下を追加します。

class Paper < ActiveRecord::Base
  belongs_to :journal
       :

次に、論文情報追加、編集ページに雑誌選択リストを追加しましょう。 どのファイルを編集すればよいかはお分かりですね。 そう、app/views/paper/_form.rhtml です。 タイトル入力フィールドの下にでも入れましょう。

<p><label for="paper_journal_id">Journal</label><br/>
<%= select "paper", "journal_id",
           Journal.find_all(nil,'abbrebiation').collect{|j| [h(j.abbreviation), j.id]},
           :include_blank=>true %>
<%= link_to 'add journal', :controller=>"journal", :action=>"new" %>
</p>

雑誌の省略名を選択すると、その id が返るようにしています。 これで、papers テーブルの journal_id に journal テーブルの id への参照が保存されます。 難しいことは何もありませんね。 また、お目当ての雑誌を見つけやすいように、 名前でソートしています。 選択する際に雑誌がまだ登録されていなかった、 という事態にそなえ、 選択フォームの下に、雑誌追加ページへのリンクもつくっています。

paper_new_journal

雑誌が選択されていない場合には、 メッセージを返すようにしましょう。 Paper モデルに検証を組み込むのは簡単でしたね。

validates_presence_of :authors, :title, :year, :file_url, :priority, :journal_id

リスト表示ページと詳細情報表示ページにも、 雑誌省略名が表示されるようにしておきましょう。 リストのファイルURLの上に表示させましょう。 app/views/paper/list.rhtml に挿入します。

<%= h(truncate(paper.title, 80)) %><br/>
</td>
 <td>
   <%= h(paper.journal.abbreviation) %><br/>
   <a href="<%= h(paper.file_url) %>">PDF file</a>
   <%= h(Paper::PRIORITIES[paper.priority-1][0]) %>
 </td>
<td class="ListActions">

paper_list_journal

詳細表示のページには、項目の一番下に表示するのが簡単ですね。 app/views/paper/show.rhtml に追加しましょう。

<% end %>
<p>
  <b>Journal:</b> <%= h @paper.journal.name %> (<%= h @paper.journal.abbreviation %>)
</p>

<%= link_to 'Edit', :action => 'edit', :id => @paper %> |

paper_show_journal

これで、 雑誌情報を論文情報管理アプリケーションに組み込むことができました。 早速、いくつか論文情報を登録してみましょう。 ちゃんと、雑誌情報が追加されていますね。

4.4 共通レイアウト

論文情報管理と雑誌情報管理ができるようになりましたね。 さて、論文のリストを見ているときに、雑誌のリストが見たくなりました。 追加ページに行って、 先ほどつくった論文追加ページのリンクをクリックして、 Backのリンクをクリックしていきますか? それとも、ブラウザのURL入力欄に、 http://localhost:3000/journal/list と入力しますか? そんなことはしたくありませんね。 どこかにリンクを作くっておくべきです。 すべてのページにそれぞれリンクを作るのは、 間違いの増えるもとですし、なにしろ面倒ですよね。 一カ所に書くだけで、どのページを見ているときにもみえる、 そんな夢のようなことをかなえてくれるのが レイアウト です。 app/views/layouts ディレクトリを覗いてみましょう。

> ls app/views/layouts
paper.rhtml  journal.rhtml

実は、scffold ジェネレーターが、それぞれにレイアウトを作っていたのです。 そこで、共通のレイアウトを作って、paper, journal 両方のビューが、 その共通レイアウトを使うように設定しましょう。 では、共通レイアウトを作りましょう。 app/views/layouts/admin.rhtml という名前にしましょう。

<html>
<head>
  <title>Admin: <%= controller.action_name %></title>
  <%= stylesheet_link_tag 'scaffold', :media=>"all" %>
</head>
<body>
  <div id="banner">
    <%= @page_title || "Management of Page" %>
  </div>
  <div id="columns">
    <div id="side">
      <%= link_to("List of papers", :controller => "paper", :action => "list") %><br/>
      <%= link_to("List of journals", :controller => "journal", :action => "list") %><br/>
    </div>
    <div id="main">
      <% if @flash[:notice] -%>
        <div id="notice"><%= flash[:notice] %></div>
      <% end -%>
      <%= @content_for_layout %>
    </div>
  </div>
</body>
</html>

そしてCSSのマジックによって、左端にリンクリストが表示されるようにします。 public/stylesheets/scaffold.css に以下を追加しましょう。

#banner {
  background: #9c9;
  padding-top: 10px;
  padding-bottom: 10px;
  border-bottom: 2px solid;
  font: small-caps 40px/40px "Times New Roman", serif;
  color: #282;
  text-align: center;
}
#columns {
  background: #141;
}
#main {
  margin-left: 9em;
  padding-top: 4ex;
  padding-left: 2em;
  background: white;
}
#side {
  float: left;
  padding-top: 1em;
  padding-left: 1em;
  padding-bottom: 1em;
  width: 8em;
  background: #141;
}
#notice {
  border: 2px solid red;
  padding: 1em;
  margin-bottom: 2em;
  background-color: #f0f0f0;
  font: bold smaller sans-serif;
}
a {
  text-decoration: none;
  font: smaller sans-serif;
}
#side a {
  color: #ada;
  font: smaller sans-serif;
}
#side a:hover {
  color: #fff;
}

この共通レイアウトを使うように指示するのを忘れてはいけませんね。 それぞれのコントローラー内で layout() メソッドを使用します。 app/controllers/paper_controller.rb に追加します。

class PaperController < ApplicationController
  layout "admin"

app/controllers/journal_controller.rb にも追加します。

class JournalController < ApplicationController
  layout "admin"

app/views/layouts ディレクトリ以下の paper.rhtml, journal.rhtml は もはや必要ありませんから、無駄なものが嫌いな人は削除しましょう。

ではブラウザで確認してください。 どのページを見てもおなじレイアウトが適用されているのが分かりますね。 しかも、高級感が増したような気がしませんか。 楽ができて、しかも見栄えもよくなる、一石二鳥ですね。

paper_list_layout

journal_list_layout

5 キーワード管理

大分できあがってきましたね。 それでは、いよいよキーワードを付け加えて行きましょう。

雑誌情報のときと同じように、 キーワードは複数の論文で重複するかもしれないので、 別テーブルを用意します。 キーワードになりうる可能性がある単語のデータベーステーブルと、 それを管理するアプリケーション、 そして、論文情報と単語を結び付けるデータベーステーブルが必要になります。

5.1 Word テーブルの作成

まずは、単語のデータベーステーブルから。 いつものように db/create.sql に追加します。

drop table if exists words;
create table words (
  id            int     not null auto_increment,
  name          varchar(100)    not null,
  primary key(id)
);

id と名前だけです。 ちょっとさみしい気もしますが、 これでもれっきとしたデータベーステーブルです。 これも毎度のことですね、実際にデータベースにこのテーブルをつくりましょう。

> mysql paper_development < db/create.sql

これでデータベース側の変更ができました。

5.2 単語管理アプリケーションの作成

お馴染の scaffold ジェネレーターで、 単語管理アプリケーションを作成します。

> ruby script/generate scaffold Word Word
                   :
overwrite public/stylesheets/scaffold.css? [Ynaq] n

雑誌情報管理アプリケーションのときの繰り返しです。 入力データの検証コードを app/models/word.rb に追加します。

class Word < ActiveRecord::Base
  validates_presence_of :name
  validates_uniqueness_of :name
end

どんどんいきましょう。 リストの見た目を変えます (app/views/word/list.rhtml)。 単語は短いので、一行に5つ並べましょう。

<h1>Listing keywords</h1>
<table cellpadding="5" cellspacing="0">
  <tr>
<%
row = 5
num = 0
for word in @words
%>
    <td align="center" class="ListLine<%= num%2 %>">
      <span class="ListName"><%= h(word.name) %></span>
    </td>
    <td valign="right" class="ListLine<%= num%2 %>">
      <span class="ListActions">
        <%= link_to 'Edit', :action => 'edit', :id => word %><br />
        <%= link_to 'Destroy', { :action => 'destroy', :id => word }, :confirm => 'Are you sure?', :post => true %>
      </span>
    </td>
<%
  if num%row == row-1
%>
  </tr>
  <tr align="center">
<%
  end
  num += 1
end
%>
  </tr>
</table>
<br/>
<hr>
<% if @word_pages.length > 1 %>
<p>
page: <%= pagination_links @word_pages %>
</p>
<% end %>
<p>
<%= link_to 'New word', :action => 'new' %>
</p>

また、標準では、リストは1ページにつき10項目しか表示されないので、 50に増やしましょう。 名前順に表示されるようにしておきましょうね。 コントローラーのリストアクションを編集します (app/controllers/word_controller.rb)。

def list
  @word_pages, @words
     = paginate :words, :per_page => 50, :order => 'name'
end

先ほどつくった共通レイアウトに、 このリストへのリンクを追加しましょう。

<div id="side">
  <%= link_to("List of papers", :controller => "paper", :action => "list") %><br/>
  <%= link_to("List of journals", :controller => "journal", :action => "list") %><br/>
  <%= link_to("List of keywords", :controller => "word", :action => "list") %><br/>
</div>

このレイアウトが、Word ビューでも使われるようにしないといけませんね。 Word コントローラーに指示すればよいのでしたね。 app/controllers/word_controler.rb に追加します。

class WordController < ApplicationController
  layout "admin"

気になる人は、app/views/layouts/word.rhtml を削除しましょう。

あと、詳細表示ページが必要ないと思う人は無くしましょう。 app/controllers/word_controller.rb 内の Word コントローラーの関連個所を編集するんでしたね。 何をすればよいのか忘れた人は、 雑誌情報管理アプリケーションの該当部分を読み直してくださいね。 また、ビューから詳細表示ページを削除しておきましょうね。

word_new_0

word_list_new

5.3 Keyword テーブルの作成

単語管理ができるようになりました。 次は、論文情報と単語を結び付けるテーブルです。 雑誌情報の時は、論文情報のテーブルにリンクをつけるだけだったのに、 なぜ今回はまたもう一つ別のテーブルが必要になるのでしょうか。 それは、一つの論文情報にキーワードは複数つけたいからです。 基本的には、データベースは 1対1 ですから、 雑誌情報のときのように単純にはいきません。 論文情報テーブルと単語テーブルの両方のリンクをもつテーブルを 新たに用意する必要があるのです。 たとえば、一つの論文情報に3つのキーワードをつけたい場合、 この論文情報の id と、それぞれの単語の id をもつ 3つの行を、 この新しいテーブルに追加することになります。

では、db/create.sql に追加しましょう。

drop table if exists keywords;
create table keywords (
  id            int     not null auto_increment,
  paper_id      int     not null,
  word_id       int     not null,
  constraint fk_keyword foreign key (paper_id) references papers(id),
  constraint fk_keyword foreign key (word_id) references words(id),
  primary key(id)
);

そして、データベースにテーブルを作成ですね。

> mysql paper_develepment < db/create.sql

5.4 キーワードを論文管理アプリケーションに組み込む

keywords テーブルをつくりましたから、 Rails で扱えるように、Keyword モデルをつくりましょう。

> ruby script/generate model Keyword

では、いよいよ論文管理アプリケーションに、 キーワードを追加していきます。

データベーステーブルに外部参照リンクがありますから、 モデルに参照していることを教えてあげなければなりませんよね。 app/models/keyword.rb 内の Keyword モデルクラスに追加します。

class Keyword < ActiveRecord::Base
  belongs_to :paper
  belongs_to :word
end

今回は、参照されている側にも教えてあげる必要があります。 Rails が用意している has_many() メソッドを使いましょう。 app/models/paper.rb 内の Paper モデルクラス、 app/models/word.rb 内の Word モデルクラスに、 それぞれ以下を追加します。

class Paper < ActiveRecord::Base
  has_many :keywords, :dependent => true
       :

class Word < ActiveRecord::Base
  has_many :keywords, :dependent => true
       :

ここで、:dependent オプションが設定されています。 このオプションを true にすると、 参照元が削除されたときに、 それを参照しているデーターベーステーブルのすべての行を Rails が自動的に削除してくれます。 今回の場合、論文情報や、単語を削除したときには、 その論文や単語に関する関係情報はまったく意味が無くなりますから、 データーベースから削除したほうがよいですよね。

では、論文情報追加、編集ページに雑誌選択チェックボックス一覧を追加しましょう。 編集するのは app/views/paper/_form.rhtml でしたね。

<p><label for="keyword_word_id">Keywords</label><br/>
<table>
  <tr>
<%
row = 5
num = 0
for word in Word.find_all(nil,'name')
  name = word.name
  flag = @paper.keywords.find(:first, :conditions=>["word_id = ?",word.id])
%>
    <td>
      <%= check_box_tag("word[#{name}]", 1, flag, :id=>"word_#{name}") %>
      <%= hidden_field("word", name) %>
      <label for="word_<%= name -%>"><%= h(name) -%></label>
    </td>
<% if num%row == row-1 %>
  </tr>
  <tr>
<%
  end
  num += 1
end
%>
  </tr>
</table>
<%= link_to 'add keyword', :controller=>"word", :action=>"new" %>
</p>

単語データーベーステーブルに登録されたすべての単語を、 abc順に並べ替えて、それぞれにチェックボックスをつくっています。 チェックされると 「1」が返ります。 チェックボックスの性質上、 チェックされなかった単語に関しては何も返されません。 そこで、hiddenフィールドをつくって、 チェックされなかった単語に関しても情報が返るようにしています。 チェックボックス一覧の下に、 単語追加ページへのリンクもつくっておきましょう。

Create もしくは Update ボタンが押されると、 ここでチェックされた単語情報は、Paper コントローラーにわたります。 これまでは、入力情報は自動で papers テーブルに書き込まれました。 しかし、ここでは、keywords テーブル にこの情報を書き込む必要があります。 この手順は手動で app/controllers/paper_controller.rb 内の Paper コントローラーに指示してあげる必要があります。

def create
  @paper = Paper.new(params[:paper])
  if @paper.save && update_keywords
             :

def update
  @paper = Paper.find(params[:id])
  if @paper.update_attributes(params[:paper]) && update_keywords
             :

private
def update_keywords
  flag = true
  params[:word].each{|word,checked|
    word_id = Word.find(:first, :conditions=>["name = ?",word]).id
    keyword = @paper.keywords.find(:first, :conditions=>["word_id = ?",word_id])
    if checked=="1"
      unless keyword
        keyword = @paper.keywords.build("word_id"=>word_id)
        flag = flag && keyword.save
      end
    else
      @paper.keywords.delete keyword if keyword
    end
  }
  return flag
end

新規作成の時は create() メソッドが、 更新の時は update() メソッドが呼ばれます。 それぞれのなかで、papers テーブルが作成もしくは更新された後で、 update_keywords() メソッドを呼んで keywords テーブルを変更します。 update_keywords() メソッドでは、 まず、すでにキーワードとして登録されているかを、 @paper.keywords.find() メソッドで調べます。 登録されていた場合は keyword 変数にその Keyword モデルクラスが、 されていない場合は、nil が代入されます。 次に、フォームでチェックされているかどうかを調べ、 されていた場合で、まだキーワードとして登録されていない場合は、 新たに keywords テーブルに追加します。 フォームでチェックされておらず、 かつ、キーワードとして登録されていた場合は、 そのキーワード登録を削除します。 flag 変数を使って、keywords テーブルへの追加が失敗した場合には、 update_keywords() メソッドは false を返すようにしています。

paper_new_keyword

リスト表示ページと詳細情報表示ページにも、 キーワードが表示されるようにしておきましょう。 タイトルの下に表示させましょう。 app/views/paper/list.rhtml に挿入します。

<td width="60%">
  <span class="ListName"><%= h(paper.authors) %> (<%= h(paper.year) %>)</span><br />
  <%= h(truncate(paper.title, 80)) %><br/>
  <%= paper.keywords.collect{|kw| kw.word.name}.sort.join(", ") %>
</td>

paper_list_keyword

詳細表示のページの項目の一番下にも表示しましょう。

<p>
  <b>Keywords:</b> <%= @paper.keywords.collect{|kw| h kw.word.name}.sort.join(", ") %>
</p>

paper_show_keyword

これで、 キーワードが論文情報管理アプリケーションに組み込まれました。

6 検索

論文情報の登録、管理についてはほぼ完成しました。 では次は、検索に取りかかりましょう。 検索には、 キーワードと重要度を与えることにします。 名前や印刷年が分かっている場合は、 この検索アプリケーションを使うまでもありませんよね。 追加するのは簡単ですから、 必要な人はチャレンジしてみてください。

6.1 Search コントローラーの作成

ここでは、新たにデータベーステーブルを作る必要はありませんね。 ですから、モデルは必要ありません。 コントローラーを作りましょう。

> ruby script/generate controller Search

では Search コントローラーに検索機能をつけましょう。 追加すべきアクションは2つです。 1つ目は、検索画面の表示のための準備ですが、特に何もすることはありません。 2つ目は、指定された検索パラメターを受取り、 合致するアイテムを見つけ、 結果をビューに渡せるようにインスタンス変数に格納することです。 編集すべきは app/controllers/search_controller.rb でしたね。

class SearchController < ApplicationController
  def index
  end

  def list_results
    @words = Array.new
    hits = Hash.new(0)
    params[:word].each{|word,checked|
      if checked=="1"
        @words.push word
        word_id = Word.find(:first, :conditions => ["name = ?",word]).id
        Keyword.find(:all, :conditions=>["word_id = ?",word_id]).each{|keyword|
          hits[keyword.paper_id] += 1
        }
      end
    }
    if @words.length == 0
      @papers = []
      return
    end
    priorities = Array.new
    params[:priority].each{|key,val|
      priorities.push key if val=="1"
    }
    if priorities.length == 0
      @papers = []
      return
    end
    result = Array.new
    conditions = "priority IN (" + (["?"]*priorities.length).join(",") + ")"
    conditions = [conditions] + priorities
    Paper.find(:all, :conditions=>conditions).each{|paper|
      unless (n=hits[paper.id])==0
        result.push [paper.priority, n, paper]
      end
    }
    result.sort!
    @papers = result.collect{|ary| ary[2] }
  end
end

検索過程では、 まず、選択されたキーワードが、 それぞれの論文にいくつ含まれているかを数えます。 次に、選択された重要度に当てはまる論文を探しだします。 最後に、結果を、重要度、キーワードのヒット数でソートし、 @papers 変数に代入します。 また、ヒットしたキーワードを @word 変数に代入しています。

6.2 Search ビューの作成

では、ビューも用意しましょう。 最初に、検索画面です。 app/views/search/index.rhtml を編集しましょう。

<% @page_title = "Search Papers" -%>
<%= start_form_tag(:action => "list_results") %>
<p>
<b>Keywords</b><br/>
<table>
  <tr>
<%
row = 5
num = 0
for word in Word.find_all.collect{|word| word.name}.sort
%>
    <td>
      <%= check_box("word",word) %>
      <label for="word_<%= word %>"><%= h(word) %></label>
    </td>
<% if num%row == row-1 %>
  </tr>
  <tr>
<%
  end
  num += 1
end
%>
  </tr>
</table>
</p>
<p>
 <b>Priority</b><br/>
 <% for priority in Paper::PRIORITIES %>
   <%= check_box("priorities",priority[1]) %>
   <label for="priorities_<%= priority[1] %>"><%= h(priority[0]) %></label>
 <% end %><br\>
</p>
<%= submit_tag("search") %>
<%= end_form_tag %>

次は、検索結果表示画面です。

<h1>Listing results</h1>
<p>
<%= @papers.length %> results were found.<br/>
</p>
<p>
<table cellpadding="5" cellspacing="0">
<%
odd_or_even = 0
for paper in @papers
  odd_or_even = 1 - odd_or_even
%>
  <tr valign="top" class="ListLine<%= odd_or_even %>">
    <td width="60%">
      <span class="ListName"><%= h(paper.authors) %> (<%= h(paper.year) %>)</span><br />
      <%= h(truncate(paper.title, 80)) %><br/>
      <%= paper.keywords.collect{|kw|
                                  name = kw.word.name
                                  if @words.include?(name)
                                     "<span class=\"Hit\">#{h(name)}</span>"
                                  else
                                    h(name)
                                  end
                                 }.sort.join(", ") %>
    </td>
    <td>
      <%= h(paper.journal.abbreviation) %><br />
      <a href="<%= h(paper.file_url) %>">PDF file</a><br />
      <%= h(Paper::PRIORITIES[paper.priority-1][0]) %>
    </td>
  </tr>
<% end %>
</table>
</p>
<%= link_to 'back to search', :action => 'index' %>

コントローラーからわたってきた結果のリストを表示します。 Paper ビューのリストとほとんど同じですが、 編集関係のリンクは除いてあります。 また、ヒットしたキーワードは強調するようにしています。

レイアウトを管理用のレイアウトと見た目を同じにしましょう。 app/views/layouts/search.rhtml を変更します。

<html>
<head>
  <title>Search Paper: <%= controller.action_name %></title>
  <%= stylesheet_link_tag 'scaffold', :media=>"all" %>
</head>
<body>
  <div id="banner">
    <%= @page_title || "Search Papers" %>
  </div>
  <div id="columns">
    <div id="side">
      <%= link_to("Search", :action => "index") %><br/>
      <hr>
      <%= link_to("Admin", :controller => "paper", :action => "index") %>
    </div>
    <div id="main">
      <% if @flash[:notice] -%>
        <div id="notice"><%= flash[:notice] %></div>
      <% end -%>
      <%= @content_for_layout %>
    </div>
  </div>
</body>
</html>

CSS (public/stylesheets/scaffold.css) に以下を追加しましょう。

.Hit {
  color: #ff0000;
  background-color: #ffffff;
}

search_index_new

search_list_1

管理用ページレイアウト (app/views/layouts/admin.rhtml) にも検索ページへのリンクを付け加えておきましょう。

<div id="side">
  <%= link_to("List of papers", :controller => "paper", :action => "list") %><br/>
  <%= link_to("List of journals", :controller => "journal", :action => "list") %><br/>
  <%= link_to("List of keywords", :controller => "word", :action => "list") %><br/>
  <hr>
  <%= link_to("Search", :controller => "search", :action => "index") %>
</div>

これで、検索アプリケーションができました。

7 ユーザー管理

基本的な、論文管理&検索アプリケーションができました。 では、管理機能には制限を加え、 ログインしているユーザー以外は管理機能にアクセスできないようにしましょう。

7.1 ユーザー情報管理

管理者のユーザー名、パスワードを格納する、 データベーステーブルを作りましょう。 パスワードは平文のまま格納するのではなく、 SHA-1ダイジェストを作成して、 その結果の160ビットハッシュ値を格納します。 いつものように db/create.sql に追加します。

drop table if exists users;
create table users (
  id                    int             not null auto_increment,
  name                  varchar(100)    not null,
  hashed_password       char(40)        null,
  primary key (id)
);

> mysql paper_development < db/create.sql

続いてモデルも作成します。

> ruby script/generate model User

次に、ユーザーに関する諸機能をもつコントローラーを作りましょう。 必要な機能は、ユーザーの追加、一覧、パスワード変更、削除、ログイン、ログアウトです。 コントローラーを作ると同時に、これらのアクションも同時に作ってしまいましょう。

> ruby script/generate controller Login add_user list_users change_password delete_user login logout

では、ユーザー追加機能を実際に作っていきましょう。 Login コントロールの add_user アクションを編集していきます ( app/controllers/login_controller.rb )。

class LoginController < ApplicationController
  def add_user
    if request.get?
      @user = User.new
    else
      @user = User.new(params[:user])
      if @user.save
        flash[:notice] = "User #{@user.name} was created"
        redirect_to :action=>'list_users'
      end
    end
  end

少しだけややこしいですね。今までの論文情報などの新規追加手順では、 追加フォーム表示のために、new を呼んで、 フォームのデータを create が受け取って、データベースに保存していました。 ここでは、2つに分ずに、add_user が両方の役目をこなします。 フォームを表示する際には、ブラウザから何もデータは受け取りません。 一方、保存するときには、データを受け取ります。 ブラウザから送るデータが無いときは、 ブラウザはGETリクエストを送ります。 一方、データを送るときは、POSTリクエストを送ります。 この違いを利用します。 request の get?() メソッドでリクエストがGETであるかどうか調べ、 GETであれば、フォームを表示、 そうでなければ、POSTだと判断し、 受け取ったデータをデータベースに保存するようにしています。

では、対応するビューを作っていきましょう。 app/views/login/add_user.rhtml を作ります。

<% @page_title = "Adding User" -%>
<%= error_messages_for 'user' %>
<%= form_tag %>
<table>
  <tr>
    <td>User name:</td>
    <td><%= text_field("user", "name") %></td>
  </tr>
  <tr>
    <td>Password:</td>
    <td><%= password_field("user", "password") %></td>
  </tr>
  <tr>
    <td></td>
    <td><input type="submit" value=" add user "/></td>
  </tr>
</table>
<%= end_form_tag %>

ここでは、form_tag() メソッドにはパラメータを渡す必要がありません。 なぜならば、デフォルトでは、 このページを表示したアクションを呼び出すようになっているからです。

管理ページ共通レイアウトを使うようにしましょう。

class LoginController < ApplicationController
  layout "admin"  

これで、ユーザー追加機能は完成しました...、と言いたいところですが、 このままだとうまく行きません。 フォームに入力された平文のパスワードを、 User モデルの password 属性に格納する様に add_user.rhtml を書きました。 ところが、users テーブルに格納すべきはハッシュ化されたパスワードですから、 User モデルは hashed_password 属性は持っているものの、 password 属性はもっていません。 そこで、モデル内に password 属性を作成しましょう。

class User <ActiveRecord::Base
  attr_accessor :password

次に、平文で password 属性に格納されたパスワードを、 データベースに保存する前に、ハッシュ化し、 hashed_password 属性に格納する必要があります。 この処理は、ActiveRecord に組み込まれているフック機能を使って実現できます。 before_save() フックを使うことにより、 データーベースに保存される前に、パスワードのハッシュ化と、 hashed_password 属性への格納を行います。 また、after_create() フックを使って、平文のパスワードフィールドをクリアします。 これは、ユーザーオブジェクトがその後、何者かに内容を見られる可能性があるセッションデータに格納されてしまうからです。

require "digest/sha1"
class User < ActiveRecord::Base
  attr_accessor :password
  attr_accessible :name, :password
  validates_presence_of :name, :password
  validates_uniqueness_of :name
  def before_save
    self.hashed_password = User.hash_password(self.password)
  end
  def after_save
    @password = nil
  end
  private
  def self.hash_password(password)
    Digest::SHA1.hexdigest(password)
  end
end

attr_accessible は、指定された属性以外の属性が、 フォームの入力によって自動的に設定されないようにしています。 こうすることで、何者かが自分でつくったフォームにより、 hashed_password 属性を直接書き込むことを禁止できます。 これで、無事、データベースにユーザーを追加することができるようになりました。

login_add_1

では、ユーザーのリスト機能をつくりましょう。 まずはコントローラーから。list_users アクションを追加します。

class LoginController < ApplicationController
   :
  def list_users
    @all_users = User.find_all
  end

ビューも用意しましょう。

<% @page_title = "List of Users" -%>
<p>
<table>
<% for user in @all_users -%>
  <tr>
    <td><%= user.name %></td>
    <td><%= link_to("delete", :action => :delete_user, :id => user) %></td>
  </tr>
<% end -%>
</table>
</p>
<%= link_to "add user", :action => :add_user %>

login_list_1

では、パスワード変更機能もつけましょう。 change_password アクションを変更します。

def change_password
  if request.get?
    @name = user.name
  else
    user.password = params[:password]
    if user.save
      flash[:notice] = "Password was successfully changed"+params[:password]
      redirect_to :action => 'list_users'
    else
      @name = user.name
      render :action => 'change_password'
    end
  end
end

ビューも追加します(app/views/login/change_password.rb)。

<% @page_title = "Change password" -%>
<%= error_messages_for 'user' %>
<%= form_tag %>
<table>
  <tr>
    <td>User name:</td>
    <td><%= @name %></td>
  </tr>
  <tr>
    <td>Password:</td>
    <td><input id="password" name="password" size="30" type="password" /></td>
  </tr>
  <tr>
    <td></td>
    <td><input type="submit" value=" change password "/></td>
  </tr>
</table>
<%= end_form_tag %>

リストにパスワード変更リンクもつけましょう

<% for user in @all_users -%>
  <tr>
    <td><%= user.name %></td>
    <td><%= link_to("change password", :action => :change_password, :id => user) %><br/>
        <%= link_to("delete", :action => :delete_user, :id => user) %></td>
  </tr>
<% end -%>

あとは、ユーザー削除機能ですね。 ここで一つ注意しなければいけないことがあります。 管理者が全員削除されてしまうと、 管理ができなくなります。 現時点では、まだ誰でも管理者追加ができるので問題はありませんが、 管理者追加もログインした管理者以外はできなくした後は、 たいへんなことになりますね。 この事態を防ぐために、一人特別な管理者をつくり、 そのユーザは削除できないようにします。 ここでは、そのユーザーの名前を root にしています。

この処理も、フックを利用することで簡単に行うことができます。 User モデルにフックを追加しましょう。

class User < ActiveRecord::Base
  before_destroy :dont_destroy_root
  def dont_destroy_root
    raise "root cannot be destroyed" if self.name == "root"
  end

次に、コントローラーに delete_user アクションを追加します。

def delete_user
  id = params[:id]
  if id && user = User.find(id)
    begin
      user.destroy
      flash[:notice] = "User #{user.name} wad deleted"
    rescue
      flash[:notice] = "The user #{user.name} could not be deleted"
    end
  end
  redirect_to :action => :list_users
end

フック内で例外が発生すると、 delete_user アクション内でその例外が補足され、 適切なメッセージがフラッシュに格納されます。

login_delete_error

では、データベースに users テーブルを作るときに、 同時に root ユーザーを作るようにしましょう。 root ユーザーが存在しないと、先ほど考えた仕組みは成り立ちませんからね。 初期パスワードは、空文字列 '' にします。 後で変更してください。 空文字列のハッシュ文字列は "da39a3ee5e6b4b0d3255bfef95601890afd80709" です。 db/create.sql の users テーブルの作成の下に付け加えましょう。

insert into users values (1,'root','da39a3ee5e6b4b0d3255bfef95601890afd80709');

これで、ユーザー情報管理機能ができました。

7.2 ログイン

管理者向けログイン機能を追加するには、 次のことを実現しなければなりません。

まず、Login コントローラーに login アクションを追加します。 ここでは、ユーザー名とパスワードが正しければ、 そのユーザー id をセッションに記録し、ログイン済であることを記録する 処理を行います。

def login
  if request.get?
    session[:user_id] = nil
    @user = User.new
  else
    @user = User.new(params[:user])
    logged_in_user = @user.try_to_login
    if logged_in_user
      session[:user_id] = logged_in_user.id
      redirect_to :action=>'index'
    else
      flash[:notice] = "invalid user name or password was given"
    end
  end
end

request の使い方は add_user の場合と同じですね。 セッションへの保存は、session 変数へ格納することで行うことができます。 では、User もでるに try_to_login メソッドを付け加えましょう。

def try_to_login
  User.login(self.name, self.password)
end
def self.login(name, password)
  hashed_password = hash_password(password || "")
  find(:first, :conditions => ["name = ? and hashed_password = ?", name, hashed_password])
end

login ビューは add_user ビューとほとんど同じです。

<%= form_tag %>
<table>
  <tr>
    <td>User name:</td>
    <td><%= text_field("user", "name") %></td>
  </tr>
  <tr>
    <td>Password:</td>
    <td><%= password_field("user", "password") %></td>
  </tr>
  <tr>
    <td></td>
    <td><input type="submit" value=" login "/></td>
  </tr>
</table>
<%= end_form_tag %>

login_login_1

では、ログアウト機能もつけておきましょう。 Login コントローラーに logout アクションを追加します。

def logout
  session[:user_id] = nil
  flash[:notice] = "You logouted"
  redirect_to :action => "login"
end

ログイン情報を保持するセッションに nil を格納し、 メッセージをフラッシュに格納、 あとはログインページにジャンプするだけです。

logout ビューは必要ないので、 気になる人は、app/views/login/logout.rhtml は削除しておきましょう。

login_logout_1

最後に、index ページを追加しておきましょう。 これは、管理者がログイン後に最初に目にするページです。 少しでも有益なページにするために、 登録された論文、雑誌、キーワードの総数を表示することにします。 まずコントローラーに index アクションを追加します。

def index
  @total_papers = Paper.count
  @total_journals = Journal.count
  @total_words = Word.count
end

そして、index ビューです。

<% @page_title = "Management of Papers" -%>
<h1>Status</h1>
<table>
  <tr>
    <td>total number of papers:</td>
    <td><%= @total_papers %></td>
  </tr>
  <tr>
    <td>total number of journals:</td>
    <td><%= @total_journals %></td>
  </tr>
  <tr>
    <td>total number of keywords:</td>
    <td><%= @total_words %></td>
  </tr>
</table>

login_index_1

7.3 アクセス管理

それでは、肝心のアクセス管理の機能を付け加えていきます。 ログイン済み管理者以外は、サイト管理ページにアクセスできないようにします。 この仕組みは、Railsの フィルタ 機能を利用して簡単に実装できます。 Rails のフィルタを使用すると、アクションメソッドの呼び出しを補足して、 その呼び出しの実行直前およびその呼び出しからの復帰直後に 独自の処理を加えることができます。 ここでは、Paper, Journal, Word コントローラー内のすべてのアクション に対するすべての呼び出しを補足し、 その実行前に、session[:user_id] の値の存在を確認します。 この値が、設定されている場合は、 ログイン済みの管理者による操作であると判断し、 アクションに対いする呼び出しをそのまま続行させます。 値が設定されていない場合は、 アクションの呼び出しを許可せずにログインページにリダイレクトします。

このフィルタは、 すべてのコントローラの親クラスである ApplicationController 内に記述します。 このクラスは app/controllers/application.rb ファイルで定義されています。

class ApplicationController < ActionController::Base
  def authorize
    unless session[:user_id]
      flash[:notice] = "Please login"
      redirect_to :controller => "login", :action => "login"
    end
  end
end

管理すべき Paper, Journal, Word コントローラに

before_filter :authorize

を足しましょう。 これにより、これらのコントローラーのすべてのアクションが実行される前に、 ログイン済であるかどうかチェックされます。 しかし、Login コントローラーの login アクションや、 Search コントローラーのすべてのアクションは ログインしていないユーザーでも呼び出せるようにしなければならないので、 次のように、これらのアクションをフィルタの対象から除外する指定を追加します。

class LoginController < Application Controller
  before_filter :authorize, :except => :login

class SearchController < ApplicationController
  before_filter :authorize, :except => [:index, :list_results]

login_login_error

7.4 レイアウト

これで、アプリケーションに管理機能が追加されました。 レイアウトのも修正しておきましょう

管理用レイアウトのサイドバーには、 ログイン状態に合わせて適切な表示をするようにしましょう。

<html>
<head>
  <title>Admin: <%= controller.action_name %></title>
  <%= stylesheet_link_tag 'scaffold', 'papers', :media=>"all" %>
</head>
<body>
  <div id="banner">
    <%= @page_title || "Management of Papers" %>
  </div>
  <div id="columns">
    <div id="side">
      <% if session[:user_id] -%>
        <%= link_to("List of papers", :controller => "admin", :action => "list") %><br/>
        <%= link_to("List of journals", :controller => "journal", :action => "list") %><br/>
        <%= link_to("List of keywords", :controller => "word", :action => "list") %><br/>
        <hr/>
        <%= link_to("Add user", :controller => "login", :action => "add_user") %><br/>
        <%= link_to("List of users", :controller => "login", :action => "list_users") %><br/>
        <hr/>
        <%= link_to("logout", :controller => "login", :action => "logout") %><br/>
        <hr/>
      <% end -%>
      <%= link_to("search", :controller => "search", :action => "index") %>
    </div>
    <div id="main">
      <% if @flash[:notice] -%>
        <div id="notice"><%= flash[:notice] %></div>
      <% end -%>
      <%= @content_for_layout %>
    </div>
  </div>
</body>
</html>

layout_admin_logout

layout_admin_login

検索用レイアウトのサイドバーには、雑誌リストへのリンクをつけていましたが、 ログイン画面へのリンクに変更しましょう。

<div id="side">
  <%= link_to("Top", :action => "index") %><br/>
  <hr/>
  <%= link_to("Admin menu", :controller => "login", :action => "login") %><br/>
</div>

8 RJS

RJS については RubyOnRails を使ってみる 【第 7 回】 RJS を使ってみる を見てください。 要は、JavaScript を使って、見た目、操作性を向上しましょうということです。

では、論文管理アプリケーションも RJS を使ってより便利にしていきましょう。

8.1 雑誌情報、キーワードの追加の改良

論文情報を追加しているときに、 雑誌やキーワードが登録されていない場合のために、 追加用のリンクをつけました。 しかし、追加リンクから、雑誌やキーワードを追加した後、 また一から論文情報を入力し直さないといけません。 これは不便です。 論文情報の入力情報を保持したまま、 雑誌やキーワードを追加したいですね。

追加リンクをクリックすると、 論文情報入力フォームが見かけ上消えて、 雑誌やキーワードの入力フォームが現れ、 それらが作成されると、 もとの論文情報入力フォームがもとに戻る、 というようにしましょう。

雑誌情報からいきます。 まず、JavaScript を使うための設定を読み込むようにします。 admin レイアウト(app/views/layouts/admin.rhtml) のヘッダー部分に以下を挿入します。

<head>
   :
  <%= javascript_include_tag :defaults %>
</head>

では、雑誌情報入力ページを編集して、 論文情報入力フォームを見えないようにしたり、 雑誌情報やキーワード入力フォームを挿入したり できるようにしましょう。 app/views/paper/new.rhtml を編集します。

<div id="new_paper">
<h1>New paper</h1>

<%= start_form_tag :action => 'create' %>
  <%= render :partial => 'form' %>
  <%= submit_tag "Create" %>
<%= end_form_tag %>

<%= link_to 'Back', :action => 'list' %>
</div>

<div id="work_space">
</div>

<div> を使って、それぞれの要素に id をつけています。 id をつけることによって、後からその部分を操作できるようになります。 edit.rhtml も同様ですね。

<div id="new_paper">
<h1>Editing paper</h1>

<%= start_form_tag :action => 'update', :id => @paper %>
  <%= render :partial => 'form' %>
  <%= submit_tag "Edit" %>
<%= end_form_tag %>

<%= link_to 'Show', :action => 'show', :id => @paper %> |
<%= link_to 'Back', :action => 'list' %>
</div>

<div id="work_space">
</div>

次に、app/views/paper/_form.rhtml 内の、 追加用のリンクを Ajax 用に変えます。

<%= link_to_remote 'add journal',
                  :url => {:controller=>"journal", :action=>"new_remote"} %>

ここでは、Journal コントローラーの 新しいアクション new_remote を呼び出す用にしています。 では Journal コントローラーにアクションを追加しましょう app/controllers/journal_controller.rb)。

def new_remote
  new
  render :layout => false
end

今回の場合は、あまり関係ありませんが、 layout オプションを false にして、 レイアウトを使用しないようにしています。

では、次は、ビューです。 今までは、拡張子が rhtml の ERbのテンプレートファイルを用意していました。 ここでは、RJS テンプレートを使います。 app/views/journal/new_remote.rjs を作りましょう。

page.hide 'new_paper'
page.replace_html 'work_space', :partial => 'form_remote'

ここでは、new_paper という名前の id をつけた部分を見えなくし、 work_space と名付けた部分を、_form_remote.rhtml の内容で置き換えます。 _form_remote.rhtml は

<div id="new_journal">
<h1>New journal</h1>
<%= form_remote_tag :url => {:controller => 'journal', :action => 'create_remote'} %>
<%= render :partial => 'form' %>
<%= submit_tag "Create" %>
<%= end_form_tag %>
</div>

としましょう。 こうすることで、 論文情報の追加リンクをクリックすると、 new_paper と名前のついた論文情報入力フォームは姿を消し、 下の work_space と名付けた部分に、 論文情報の入力フォームが姿を表します。 この論文情報入力フォームは、 後で操作できるように、 new_journal と名前をつけてあります。 見かけ上は、 http://localhost:3000/journal/new にアクセスした時と同じように見えますが、 ブラウザのURLは http://localhost:3000/paper/new のままであることを確認しましょう。 また、ブラウザの機能でソースファイルを見てみると、 もとの論文情報入力フォームのときのままですね。 これらのことから、コッソリと見かけだけを変えていることが よく分かります。 ブラウザの戻るボタンや、再読み込みボタンを押してみると、 そのことが実感できますね。

paper_journal_new_1

では、Create ボタンを押した時に呼び出される create_remote アクションを作っていきましょう。

def create
  if save
    redirect_to :action => 'list'
  else
    render :action => 'new'
  end
end
def create_remote
  flag = save
  render :update do |page|
    page.remove 'new_journal'
    if flag
      page << <<-"EOF"
        select = $('paper_journal_id');
        len = select.length;
        select.options[len] = new Option("#{@journal.abbreviation}",#{@journal.id});
        select.selectedIndex = len;
      EOF
    end
    page.show 'new_paper'
  end
end
 :
private
def save
  @journal = Journal.new(params[:journal])
  if @journal.save
    flash[:notice] = 'Journal was successfully created.'
    return true
  else
    return false
  end
end

まず、パラメータを受け取って、データベースに保存するところは create アクションと同じですので、 共通する動作を 切り放して、save メソッドとしています。 render :update 以下は、ビューに関係するところですから、 本来は、create_remote.rjs に書くべきですが、 なぜか、そのようにするとうまく動かなかったので、 ここに書いています。 このように、コントロールのなかに、 RJS コードを埋め込むこともできます。

では、中身を見ていきましょう。 page.remove('new_journal') で まず、work_space に埋め込んだ、 new_journal と id をつけた雑誌情報入力フォームを削除します。

つぎに、データーベースへの登録が成功した場合は、 論文情報入力フォームの雑誌選択リストに新しい雑誌を登録し、 その雑誌を選択するようにしています。 この部分は、 page.<<() メソッドを使って、 生の JavaScript で書いています。

これで、雑誌情報の追加ができるようになりました。 ただし、気になることがあります。 雑誌の登録が成功した際に、 'Journal was successfully created' をいうメッセージをフラッシュに 登録していますが、 このメッセージは、 登録が成功して、論文情報入力フォームを表示しなおした時ではなく、 その後、何らかのアクションをした時に表示されます。 1テンポ遅く、なんだか雷の稲妻と音の関係みたいですね。 気持が悪いので、直しましょう。 create_remote アクションの中で、 メッセージを表示し、 そのメッセージをフラッシュのから消す作業を付け加えます。

if flag
  :
end
if flash[:notice]
  page.replace_html 'information', "<span id='notice'>#{flash[:notice]}</span>"
end
flash[:notice] = nil
page.show 'new_paper'

information と名付けたメッセージを表示する部分に、 メッセージを表示します。 また、admin レイアウトの中でしているのと同じく、 メッセージはCSSを使って強調するため id を notice にします。 では、メッセージを表示する部分に information と名前をつけましょう。 場所は admin レイアウトの中です (app/views/layouts/admin.rthml)。

<div id="main">
  <div id="information">
    <% if @flash[:notice] -%>
      <span id="notice"><%= @flash[:notice] %></span>
    <% end -%>
  </div>
  <%= @content_for_layout %>
</div>

これで、雑誌情報の登録が成功したときに、 メッセージが表示されるようになりました。 めでたし、めでたし、.... と思いきや、 副作用が出ましたね。 続けて論文情報を追加しようと思ったら (論文情報には雑誌は1つですから、普通はこんなことしませんが、 後で足すキーワードのだとその可能性はありますね)、 そのメッセージがでたままになっています。 頑張って作ったんだから、 長い間見られてうれしいと思ったりもしますが、 やはり変なものは変です。 残念ですが直しましょう。

new_remote アクションが呼ばれたときに、 メッセージを消せばいいでしょう。 new_remote.rjs に一行加えましょう。

page.replace_html 'information', ''

これでOKですね。

では、キーワードも同様に直していきましょう。 ほとんど同じですから、 変更部分を列挙します。 まずは、app/views/paper/_form.rhtml。

<p><label for="keyword_word_id">Keywords</label><br/>
<span id="keyword_table">
<%= render :partial => 'keyword_table' %>
</span>
<%= link_to_remote 'add keyword',
                   :url => {:controller=>"word", :action=>"new_remote"} %>
</p>

切り分けた app/views/paper/_keyword_table.rhtml テンプレート。

<table>
  <tr>
<%
row = 5
num = 0
for word in Word.find_all(nil,'name')
  name = word.name
  flag = @paper.keywords.find(:first, :conditions=>["word_id = ?",word.id])
%>
    <td>
      <%= check_box_tag("word[#{name}]", 1, flag, :id=>"word_#{name}") %>
      <%= hidden_field_tag("word[#{name}]", :id=>"word_#{name}") %>
      <label for="word_<%= name -%>"><%= h(name) -%></label>
    </td>
<% if num%row == row-1 %>
  </tr>
  <tr>
<%
  end
  num += 1
end
%>
  </tr>
</table>

次は、Word コントローラー。

def new_remote
  new
end
 :
def create
  if save
    redirect_to :action => 'list'
  else
    render :action => 'new'
  end
end

def create_remote
  flag = save
  render :update do |page|
    page.remove 'new_word'
    if flag
      @paper = Paper.new
      page.replace_html 'keyword_table', :partial => 'paper/keyword_table'
      page << "$('word_#{@word.name}').checked = true;"
    end
    if flash[:notice]
      page.replace_html  'information', "<span id='notice'>#{flash[:notice]}</span>"
    end
    flash[:notice] = nil
    page.show 'new_paper'
  end
end
 :
private
def save
  @word = Word.new(params[:word])
  if @word.save
    flash[:notice] = 'Word was successfully created.'
    return true
  else
    return false
  end
end

そして、new_remote 用のビューテンプレートです。(app/views/word/new_remote.rjs)

page.replace_html 'information', ''
page.hide 'new_paper'
page.replace_html 'work_space', :partial => 'form_remote'

ここで呼び出す _form_remote.rhtml も準備しましょう。

<div id="new_word">
<h1>New Keyword</h1>
<%= form_remote_tag :url => {:controller => 'word', :action => 'create_remote'}
%>
<%= render :partial => 'form' %>
<%= submit_tag "Create" %>
<%= end_form_tag %>
</div>

雑誌情報の時と違うところが1ヶ所ありますね。 雑誌情報の時は、select リストに追加し、 それを選択するようにしました。 ここでは、 キーワドは名前の順にソートして、 横に5つづつ並べるようにしていますので、 その中に追加するのは少し大変そうです。 そこで、新しいテーブルを作り直すことにします。 テーブルを作る作業は、 最初に論文情報新規作成ページを作るときと同じですので、 共通部分は切り出すというポリシーにのっとって、 _keyword_table.rtml に分けましょう。 そして、新しくテーブルを作った後に、 今作成したキーワードにチェックを入れておきます。

これで入力情報を無駄にせずに、 新規に雑誌情報やキーワードを追加することができるようになりました。


*1RailsによるアジャイルWebアプリケーション開発の著者