はじめに
個人的な理解・備忘録を目的としてます。
筆者自身は動画版Railsチュートリアルで進めているため、アプリ作成中コード・ブランチ名などの若干の違いがありますので参考程度に流し見して頂けたら嬉しいです。
理解不足のため、何かありましたらコメント等ご指摘してくださると幸いです(^_^;)
10.0 目標
未実装だったedit、update、index、destroyアクションを加え、RESTアクションを完成させる。
その他 個人的進行
単数形と複数形
モデル(概念的)→単
それ以外→複数(ほぼ全部)
10.1 ユーザーを更新する
10.1.1 編集フォーム
編集フォームのモックアップ
(公式より参考)
まずはフィーチャーブランチを作成。
$ git checkout -b updating-users
最初はeditアクションを実装する。
# GET /users/:id/edit
def edit
@user = User.find(params[:id])
#=> app/views/users/edit.html.erb
end
end
app/views/users/edit.html.erb
<% provide(:title, "Edit user") %>
<h1>Update your profile</h1>
<div class="row">
<div class="col-md-6 col-md-offset-3">
<!--入力formの送信先を指定-->
<%= form_for(@user) do |f| %>
<!--エラーメッセージ-->
<%= render 'shared/error_messages' %>
<!--入力formを作成-->
<%= f.label :name %>
<%= f.text_field :name, class: 'form-control' %>
<%= f.label :email %>
<%= f.email_field :email, class: 'form-control' %>
<%= f.label :password %>
<%= f.password_field :password, class: 'form-control' %>
<%= f.label :password_confirmation, "Confirmation" %>
<%= f.password_field :password_confirmation, class: 'form-control' %>
<%= f.submit "Save changes", class: "btn btn-primary" %>
<% end %>
<!--編集完了ボタンとユーザー画像を表示-->
<div class="gravatar_edit">
<%= gravatar_for @user %>
<a href="http://gravatar.com/emails" target="_blank">change</a>
</div>
</div>
</div>
ユーザーのeditビュー画面の表示と、saveを押すとupdateアクションに移行しているか(エラー画面)を確認する。
Webブラウザは通常GETリクエストとPOSTの2つのリクエストのみのため、PATCHリクエストを送信できないので、RailsはPOSTリクエストと隠しinputフィールドを利用してPATCHリクエストを「偽造」している。
edit.heml.erb
とnew.html.erb
はform_for(@user)...と構造は同じだが、
editにはDBに入っている値、newはDBにない新しいインスタンスが入り、これをRailsのActive Recordにあるnew_record?メソッドが判断する。
Ruby on Rails チュートリアル 第10章 ユーザー更新 beforeフィルター フレンドリーフォワーディング adminまで
最後に、サイト内移動用のヘッダーSettingsにユーザー一覧表示用のリンクを追加する。
<li><%= link_to "Users", users_path %></li>
<li><%= link_to "Settings", edit_user_path(current_user) %></li>
10.1.2 編集の失敗
ユーザー登録に失敗したときと似た方法で、編集に失敗した場合について扱う。updateアクションを追加して失敗時の処理表示を実装する。
# PATCH /users/:id
def update
@user = User.find(params[:id])
if @user.update_attributes(user_params)
# Success
else
# Failure
#=> @user.errors.full_messages()
render 'edit'
end
end
パスワードなしで更新すると、エラーメッセージが出る。
10.1.3 編集失敗時のテスト
統合テストを生成
$ rails generate integration_test users_edit
テスト内容を記載する。流れは下記の通り。
- まず編集ページにアクセス
- editビュー(テンプレート)が描画されるかどうかをチェック
- その後、無効な情報を送信
- editビューが再描画されるかどうかをチェック
この特徴として、PATCHリクエストを送るために
patchメソッドを使っている
というものがある。patchメソッドはとは、getやpost、deleteメソッドと同じように、HTTPリクエストを送信するためのメソッド。
require 'test_helper'
class UsersEditTest < ActionDispatch::IntegrationTest
def setup
@user = users(:michael)
end
test "unsuccessful edit" do
get edit_user_path(@user)
assert_template 'users/edit'
patch user_path(@user), params: { user: { name: "",
email: "foo@invalid",
password: "foo",
password_confirmation: "bar" } }
assert_template 'users/edit'
end
end
テストが通過すればok!
10.1.4 TDDで編集を成功させる
TDD
TDDとはテスト駆動開発(Test-Driven Development: TDD)の名称で、プログラム実装前にテストコードを書き(テストファーストと呼ばれる)、動作する必要最低限な実装をとりあえず行った後にコードを改善していく手法である。
基本スタイルは
1. (RED:失敗する)テストコードを書く
2. テストに通る(GREEN:成功する)最低限のコードを書く
3. コードの重複を除去する(リファクタリング)
を繰り返すもので、アジャイル開発等でよく用いられる。
(※本記事では(公式)
の理解を目的とするため、REDは一部省略してリファクタリングに移る場合もあります)
この節では編集フォームが動作するようにする。
今回はアプリケーション用のコードを実装する前に統合テストとして受け入れテスト (Acceptance Tests)を行う。
受け入れテストとは、ある機能の実装が完了し、受け入れ可能な状態になったかどうかを決める(成功したらどうなるか?の)テスト
とされている。
先ほどのテストをベースとして、
- 今回はユーザー情報を更新する有効な情報を送信する
- 次に、flashメッセージが空でないかどうか
- プロフィールページにリダイレクトされるかどうか
- DBのユーザー情報をインスタンスに上書きする(リロード)
- データベース内のユーザー情報が正しく変更されたかどうか
test "successful edit" do
get edit_user_path(@user)
assert_template 'users/edit'
name = "Foo Bar"
email = "foo@bar.com"
patch user_path(@user), params: { user: { name: name,
email: email,
password: "",
password_confirmation: "" } }
assert_not flash.empty?
assert_redirected_to @user
@user.reload
assert_equal name, @user.name
assert_equal email, @user.email
end
もちろん成功部を実装していないためテストしてもRED。
updateアクションif文に成功パターンとして、flashと@userでリダイレクト動作を追加する。
def update
@user = User.find(params[:id])
if @user.update_attributes(user_params)
# Success
flash[:success] = "Profile updated"
redirect_to @user
else
先ほどのテストでパスワードが空で渡しているためバリデーションで弾かれるが、例外処理としてallow_nil: true
というオプションをvalidatesに追加してテストを通過 & 更新flashの表示を確認。
validates :password, presence: true, length: { minimum: 6 }, allow_nil: true
10.2 認可
editアクションとupdateアクションの動作導入はできたが、今のままでは誰でも (ログインしていないユーザーでも) ユーザー情報を編集できてしまうので、ユーザーにログインを要求し、かつ自分以外のユーザー情報を勝手に変更できないように制御する。こういったセキュリティ上の制御機構をセキュリティモデル
と呼ぶ。
(公式より参考)
(実際のところ、本人でもセッションが切れてしまった場合も含む)
この節では、ログインしていないユーザーが保護された(自分の権限のない)ページにアクセスしようとしたらログインを促すよう対処する。
認証と認可
日本語だと似たような印象になるが、
認証(英:Authentication, AuthN)
→ 何者であるかを特定すること。
ex.「〇〇ですか?」と尋ねられる、職務質問で身分証の提示を求められるなど
Railsでは、*** authenticateメソッド***
認可(英:Authorization, AuthZ)
→ 行動やリソースの使用を許可すること。
ex.「△△の資格がありますね。あのカウンターへどうぞ」と権限を認められる。
Railsでは、beforeメソッド`
<参考>
認証と認可
10.2.1 ユーザーにログインを要求する
beforeフィルター
beforeフィルターとは、before_actionメソッドを使って何らかの処理が実行される直前に特定のメソッドを実行する仕組みのこと。
今回はユーザーにログインを要求するためコントローラーに追加する。
before_actionの後にメソッド名をシンボルでlogged_in_userメソッドを定義、その後に:onlyオプション (ハッシュ) で渡されたeditアクション、updateアクションを入れることで、「only以下のアクション(edit、updateアクション)が実行される前に、最初に定義したメソッド(logged_in_user)を実行してね」という内容になる。
class UsersController < ApplicationController
before_action :logged_in_user, only: [:edit, :update]
省略
# beforeアクション
# ログイン済みユーザーかどうか確認
def logged_in_user
unless logged_in?
flash[:danger] = "Please log in."
redirect_to login_url
end
end
end
この段階ではテストしててもRED。原因としては、editアクションやupdateアクションでログインを要求するようになったため、ログインしていないユーザーだとこれらのテストが失敗するようになったため。対処としては、editアクションやupdateアクションをテストする前にログインしておくよう、log_in_asヘルパー
を実装する。
test "unsuccessful edit" do
log_in_as(@user) #=> Michaelとしてログイン
省略
test "successful edit" do
log_in_as(@user) #=> Michaelとしてログイン
テストはGREEN。
しかし、実はまだbeforeフィルターの実装はまだ終わっていない。セキュリティモデルに関する実装を取り外してもテストが通ってしまうか、beforeフィルターをコメントアウトしてテスト確認。
class UsersController < ApplicationController
# before_action :logged_in_user, only: [:edit, :update]
0 failures, 0 errors, 0 skips
通過してしまった。
beforeフィルターは基本的にアクションごとに適用していくので、Usersコントローラのテストもアクションごとに書いていく。手順としては、
1.routes
からedit、updateの正しい種類のHTTPリクエスト確認
2. そのリクエストを使ってeditアクションとupdateアクションをそれぞれ実行
3. flashにメッセージが代入されたかどうか
4. ログイン画面にリダイレクトされたかどうか
$ rails routes
Prefix Verb URI Pattern Controller#Action
sessions_new GET /sessions/new(.:format) sessions#new
root GET / static_pages#home
static_pages_home GET /static_pages/home(.:format) static_pages#home
help GET /help(.:format) static_pages#help
about GET /about(.:format) static_pages#about
contact GET /contact(.:format) static_pages#contact
signup GET /signup(.:format) users#new
POST /signup(.:format) users#create
login GET /login(.:format) sessions#new
POST /login(.:format) sessions#create
logout DELETE /logout(.:format) sessions#destroy
users GET /users(.:format) users#index
POST /users(.:format) users#create
new_user GET /users/new(.:format) users#new
edit_user GET /users/:id/edit(.:format) users#edit
user GET /users/:id(.:format) users#show
PATCH /users/:id(.:format) users#update
PUT /users/:id(.:format) users#update
DELETE /users/:id(.:format) users#destroy
editとupdateアクションの保護に対するテスト追加。
beforeフィルターが入っているかの確認(ユーザー:Michael追加)。具体的には、
1. ログインしてない状況でgetリクエスト→ユーザーの編集ページに
2. flashが出て
3. ログインにリダイレクトされるか
もう一つはpatchリクエスト(ブラウザ以外からもある)にもneforeの確認を行うもの。
require 'test_helper'
class UsersControllerTest < ActionDispatch::IntegrationTest
def setup
@user = users(:michael)
end
.
.
.
test "should redirect edit when not logged in" do
get edit_user_path(@user)
assert_not flash.empty?
assert_redirected_to login_url
end
test "should redirect update when not logged in" do
patch user_path(@user), params: { user: { name: @user.name,
email: @user.email } }
assert_not flash.empty?
assert_redirected_to login_url
end
end
テストして
2 failures, 0 errors, 0 skips
エラーでなく失敗したのok(コメントアウト解除)
10.2.2 正しいユーザーを要求する
ユーザーが自分の情報だけを編集できるようにしたい。まずはユーザーの情報が互いに編集できないことを確認するために、ユーザー用のfixtureファイル(YAML)に2人目のユーザー(Archer)を追加する。
archer:
name: Sterling Archer
email: duchess@example.gov
password_digest: <%= User.digest('password') %>
次に、log_in_asメソッド
を使ってeditアクションとupdateアクションをテスト。このとき、既にログイン済みのユーザーを対象として(①ArcherさんでログインしてMichaelさん入ろうとする、②Archerさんでログインしてpatchを送ろうとする)、ログインページではなくルートURLにリダイレクトしている点に注意。
def setup
@user = users(:michael)
@other_user = users(:archer) #=> 他ユーザー追加
end
テストではエラーになるので、beforeアクションに書き足す。
class UsersController < ApplicationController
before_action :logged_in_user, only: [:edit, :update]
before_action :correct_user, only: [:edit, :update] #順番に注意! 上から順番に「ログインしたユーザー」且つ正しいユーザー
# beforeアクション
# ログイン済みユーザーかどうか確認
def logged_in_user
unless logged_in?
flash[:danger] = "Please log in."
redirect_to login_url
end
end
# 正しいユーザーかどうか確認
def correct_user
# GET /users/:id/edit
# PATCH /users/:id
@user = User.find(params[:id])
redirect_to(root_url) unless @user == current_user
end
テストは通過。
リファクタリングとして、一般的な慣習に倣ってcurrent_user?という論理値を返すメソッドを実装。correct_userの中で使えるようにしたいので、Sessionsヘルパーの中にこのメソッドを追加する。
(旧)unless @user == current_user
(新)unless current_user?(@user)
redirect_to(root_url) unless current_user?(@user) #=> @user == current_user
# 渡されたユーザーがログイン済みユーザーであればtrueを返す
def current_user?(user)
user == current_user
end
テスト通過。
10.2.3 フレンドリーフォワーディング
フレンドリーフォワーディング
フレンドリーフォワーディングとは、ユーザーがログインした後、ログイン直前に閲覧していたページヘとリダイレクトさせる(あると便利な)機能のこと。
フレンドリーフォワーディングのテストは、ログイン手前でログインページへ(ユーザさんにログインしてもらう)
ログインした後に編集ページへアクセスするという順序を逆にするもの。
test "successful edit with friendly forwarding" do
get edit_user_path(@user)
log_in_as(@user)
assert_redirected_to edit_user_url(@user)
name = "Foo Bar"
email = "foo@bar.com"
patch user_path(@user), params: { user: { name: name,
email: email,
password: "",
password_confirmation: "" } }
assert_not flash.empty?
assert_redirected_to @user
@user.reload
assert_equal name, @user.name
assert_equal email, @user.email
end
実装してないのでテストして失敗(failure)。
ユーザーを希望のページに転送するには、リクエスト時点のページをどこかに保存しておき、その場所にリダイレクトさせる必要があり、store_location
とredirect_back_or
の2つのメソッドを使って対応する。
# 記憶したURL (もしくはデフォルト値) にリダイレクト
def redirect_back_or(default)
redirect_to(session[:forwarding_url] || default)
session.delete(:forwarding_url)
end
# アクセスしようとしたURLを覚えておく
def store_location
session[:forwarding_url] = request.original_url if request.get?
end
redirect_back_orメソッド
転送先のURLを保存する場所は(今回は一時的なものなので)DBでなくsession
を使い、もともとユーザーが行きたかった場所を保存しておいてURLがある場合はリダイレクトし、ない場合(sessionが切れたり分からなくなったら)デフォルト値にユーザーのページを表示する。終わったらsessionを消す。
デフォルトのURLは、sessionコントローラのcreateアクションに追加し、サインイン成功後にリダイレクトします
store_locationメソッド
リクエストが送られたURLをsession変数のforwarding_urlキーに格納。ただし、GETリクエストが送られたときのみ(後置if)。
ログインユーザー用beforeフィルターにstore_locationメソッドを追加する。
# ログイン済みユーザーかどうか確認
def logged_in_user
unless logged_in?
store_location #=> アクセスしようとしたURLを覚えておく
flash[:danger] = "Please log in."
redirect_to login_url
end
end
def create
user = User.find_by(email: params[:session][:email].downcase)
if user && user.authenticate(params[:session][:password])
log_in user
params[:session][:remember_me] == '1' ? remember(user) : forget(user)
redirect_back_or user #=> フレンドリーフォワーディングを備える
これでテストは通過する。
Settings
の確認もok.
10.3 すべてのユーザーを表示する
この節ではすべての(大量の)ユーザーをページごとに一覧表示、
かつsignupしたユーザーのみが閲覧できるindexアクションを実装する。
それに伴い、①DBにサンプルデータを追加する方法、②将来ユーザー数が膨大になってもindexページを問題なく表示できるようにするためのユーザー出力のページネーション (pagination=ページ分割) の方法、を学ぶ。
モックアップ
(公式より参考)
10.3.1 ユーザーの一覧ページ
indexページを不正なアクセスから守るために、まずはindexアクションが正しくリダイレクトするか検証するテスト。
#=> 習慣として、indexに関するテストは一番上に書く
test "should redirect index when not logged in" do
get users_path #=> user(s)_pathでindexのurl(/users)へgetリクエスト
assert_redirected_to login_url
end
beforeフィルターに何もないため失敗するので、beforeフィルターのlogged_in_user
にindexアクション
を追加して、このアクションを保護する。すべてのユーザーを表示するために、User.allを使ってデータベース上の全ユーザーを取得し、ビューで使えるインスタンス変数@usersに代入。
before_action :logged_in_user, only: [:index, :edit, :update] #=> 「:index」追加
def index
@users = User.all
end
ユーザーのindexビュー(app/views/users/index.html.erb)を新規に作成。
userはハッシュを受け取らないので、引数に2つ(gravatar_for userとsize: 50)を与えるとエラーが起こる。
<% provide(:title, 'All users') %>
<h1>All users</h1>
<ul class="users">
<% @users.each do |user| %>
<li>
<%= gravatar_for user, size: 50 %>
<%= link_to user.name, user %>
</li>
<% end %>
</ul>
module UsersHelper
# 引数で与えられたユーザーのGravatar画像を返す
def gravatar_for(user, options = { size: 80 }) #=> デフォでsize80追加
gravatar_id = Digest::MD5::hexdigest(user.email.downcase)
size = options[:size] #=>変数size ,下記で「?s=#{size}」追加
gravatar_url = "https://secure.gravatar.com/avatar/#{gravatar_id}?s=#{size}"
image_tag(gravatar_url, alt: user.name, class: "gravatar")
end
end
SCSSの追記
/* Users index */
.users {
list-style: none;
margin: 0;
li {
overflow: auto;
padding: 10px 0;
border-bottom: 1px solid $gray-lighter;
}
}
ビュー画面ができたので、ヘッダー(app/views/layouts/_header.html.erb)にユーザー一覧ページへのリンクを更新する。
<% if logged_in? %>
<li><%= link_to "Users", users_path %></li>
テストして通過。
10.3.2 サンプルのユーザー
indexページに複数のユーザーを表示させてみる。
まずはGemfile
にFaker gem
を追加する。
gem 'bcrypt', '3.1.12'
gem 'faker', '1.7.3' #=> 追加
データベース上にサンプルユーザーを生成するRailsタスク(サンプルユーザーを生成するRubyスクリプト)を追加。
Railsではdb/seeds.rb
というファイルを標準とする。
中身としては、
1. まずユーザー(Example User)を作る
2. Fakerの「.name」メソッドからそれっぽいユーザーを99人増やす
User.create!(name: "Example User",
email: "example@railstutorial.org",
password: "foobar",
password_confirmation: "foobar")
99.times do |n|
name = Faker::Name.name
email = "example-#{n+1}@railstutorial.org"
password = "password"
User.create!(name: name,
email: email,
password: password,
password_confirmation: password)
end
そしてbundle install。だが、筆者の場合失敗。
エラー対応:GemfileにFaker gemを追加できない
bundle install
しようとするとエラー。
サーバを止めてもダメ。
環境
Rails v5.1.6
faker v1.7.3
$ bundle install
The dependency tzinfo-data (>= 0) will be unused by any of the platforms Bundler is installing for. Bundler is installing for ruby but the dependency is only for x86-mingw32, x86-mswin32, x64-mingw32, java. To add those platforms to the bundle, run `bundle lock --add-platform x86-mingw32 x86-mswin32 x64-mingw32 java`.
Fetching gem metadata from https://rubygems.org/............
Fetching gem metadata from https://rubygems.org/.
Resolving dependencies...
Bundler could not find compatible versions for gem "i18n":
In snapshot (Gemfile.lock):
i18n (= 1.7.0)
In Gemfile:
rails (= 5.1.6) was resolved to 5.1.6, which depends on
activesupport (= 5.1.6) was resolved to 5.1.6, which depends on
i18n (>= 0.7, < 2)
faker (= 1.7.3) was resolved to 1.7.3, which depends on
i18n (~> 0.5)
Running `bundle update` will rebuild your snapshot from scratch, using only
the gems in your Gemfile, which may resolve the conflict.
対応策
fakerのバージョンを指定しない
#旧 gem 'faker', '1.7.3'
gem 'faker' #=> バージョン指定なし
再度bundle install実行。
$ bundle install
The dependency tzinfo-data (>= 0) will be unused by any of the platforms Bundler is installing for. Bundler is installing for ruby but the dependency is only for x86-mingw32, x86-mswin32, x64-mingw32, java. To add those platforms to the bundle, run `bundle lock --add-platform x86-mingw32 x86-mswin32 x64-mingw32 java`.
Fetching gem metadata from https://rubygems.org/............
Fetching gem metadata from https://rubygems.org/.
Resolving dependencies...
Using rake 13.0.1
Using concurrent-ruby 1.1.5
Using i18n 1.7.0
Using minitest 5.10.3
Using thread_safe 0.3.6
Using tzinfo 1.2.5
Using activesupport 5.1.6
Using builder 3.2.3
Using erubi 1.9.0
Using mini_portile2 2.4.0
Using nokogiri 1.10.5
Using rails-dom-testing 2.0.3
Using crass 1.0.5
Using loofah 2.3.1
Using rails-html-sanitizer 1.3.0
Using actionview 5.1.6
Using rack 2.0.7
Using rack-test 1.1.0
Using actionpack 5.1.6
Using nio4r 2.5.2
Using websocket-extensions 0.1.4
Using websocket-driver 0.6.5
Using actioncable 5.1.6
Using globalid 0.4.2
Using activejob 5.1.6
Using mini_mime 1.0.2
Using mail 2.7.1
Using actionmailer 5.1.6
Using activemodel 5.1.6
Using arel 8.0.0
Using activerecord 5.1.6
Using ansi 1.5.0
Using execjs 2.7.0
Using autoprefixer-rails 9.7.2
Using bcrypt 3.1.12
Using bindex 0.8.1
Using rb-fsevent 0.10.3
Using ffi 1.11.2
Using rb-inotify 0.10.0
Using sass-listen 4.0.0
Using sass 3.7.4
Using bootstrap-sass 3.3.7
Using bundler 1.17.3
Using byebug 9.0.6
Using coderay 1.1.2
Using coffee-script-source 1.12.2
Using coffee-script 2.4.1
Using method_source 0.9.2
Using thor 0.20.3
Using railties 5.1.6
Using coffee-rails 4.2.2
Fetching faker 2.10.1
Installing faker 2.10.1
Using formatador 0.2.5
Using ruby_dep 1.5.0
Using listen 3.1.5
Using lumberjack 1.0.13
Using nenv 0.3.0
Using shellany 0.0.1
Using notiffany 0.1.3
Using pry 0.12.2
Using guard 2.13.0
Using guard-compat 1.2.1
Using guard-minitest 2.4.4
Using multi_json 1.14.1
Using jbuilder 2.7.0
Using jquery-rails 4.3.1
Using ruby-progressbar 1.10.1
Using minitest-reporters 1.1.14
Using puma 3.9.1
Using sprockets 3.7.2
Using sprockets-rails 3.2.1
Using rails 5.1.6
Using rails-controller-testing 1.0.2
Using tilt 2.0.10
Using sass-rails 5.0.6
Using spring 2.0.2
Using spring-watcher-listen 2.0.1
Using sqlite3 1.3.13
Using turbolinks-source 5.2.0
Using turbolinks 5.0.1
Using uglifier 3.2.0
Using web-console 3.5.1
Bundle complete! 24 Gemfile dependencies, 82 gems now installed.
Gems in the group production were not installed.
Use `bundle info [gemname]` to see where a bundled gem is installed.
無事終了
(とてもありがたかった)ご参考先
Railsチュートリアルでfakerがインストールできない場合の対処法
本編へ戻ります
DBリセット(これまでの登録ユーザー初期化)、Railsタスクを実行 (db:seed) 。
$ rails db:migrate:reset
$ rails db:seed
サンプルですが、たくさんのユーザーさん登場。
10.3.3 ページネーション
ユーザーが増えたのはいいが、今度は逆に1つのページに大量のユーザーが表示されて(仮に1万人とかになったときに)重くなってしまう。
そこで解決するのが、ページネーション (pagination) **というもの。
ページネーションとは、検索などに使われてるような「1つのページに一度に〇〇個だけ表示する」**というもの。
今回は1つのページに一度に30人だけ表示するのに、シンプルとされるwill_paginateメソッド
を使う。そのためには、Gemfileにwill_paginate gem とbootstrap-will_paginate gemを両方含め、Bootstrapのページネーションスタイルを使ってwill_paginateを構成する。
gem 'faker'
gem 'will_paginate', '3.1.6'
gem 'bootstrap-will_paginate', '1.0.0'
$ bundle install
新たにpaginateメソッドを追加したため、念のためここでサーバーの再起動を行っておく。
indexページ(app/views/users/index.html.erb)でpaginationを使う
<% provide(:title, 'All users') %>
<h1>All users</h1>
<%= will_paginate %>
<ul class="users">
<% @users.each do |user| %>
<li>
<%= gravatar_for user, size: 50 %>
<%= link_to user.name, user %>
</li>
<% end %>
</ul>
<%= will_paginate %>
will_paginateメソッド
は、usersビューのコードの中から@usersオブジェクトを自動的に見つけ出し、それから他のページにアクセスするためのページネーションリンクを作成してくれる。ただし、現在の@users変数にはUser.allの結果が含まれているが 、will_paginateではpaginateメソッドを使った結果が必要となる。
必要となるデータの例は次のとおり
paginateでは、キーが:pageで値がページ番号のハッシュを引数に1を渡すと1~30までのユーザーまで出る
ちなみにpageがnilの場合、 paginateは単に最初のページを返す。
$ rails console
> User.paginate(page: 1)
User Load (1.0ms) SELECT "users".* FROM "users" LIMIT ? OFFSET ? [["LIMIT", 11], ["OFFSET", 0]]
(0.1ms) SELECT COUNT(*) FROM "users"
=> #<ActiveRecord::Relation [#<User id: 1, name: "Example User", email: "example@railstutorial.org", created_at: "2020-01-19 06:42:48", updated_at: "2020-01-19 06:42:48", password_digest: "$2a$10$xDXvcjV4nyrflH.nVpxu2uWGCeBYR5quXeo1ERVKIUE...", remember_digest: nil>, #<User id: 2,...
省略
paginateを使うことで、このアプリでユーザーのページネーションを行えるようになる。
具体的には、indexアクション内のallをpaginateメソッドに置き換えて、indexアクションでUsersをページネートする
def index
#旧 @users = User.all
@users = User.paginate(page: params[:page])
end
現在の位置(ページネーションの番号)と下のデータが一致。
10.3.4 ユーザー一覧のテスト
ユーザーの一覧ページが動くようになったので、ページネーションに対するテストを行う。
今回のテストでは、
1. ログイン
2. indexページにアクセス
3. 最初のページにユーザーがいることを確認
4. ページネーションのリンクがあることを確認
の順でテストを行う。
まずはfixtureにさらに30人のユーザーを追加する。
今後必要になるので、2人の名前付きユーザーも一緒に追加。
michael:
name: Michael Example
email: michael@example.com
password_digest: <%= User.digest('password') %>
archer:
name: Sterling Archer
email: duchess@example.gov
password_digest: <%= User.digest('password') %>
lana:
name: Lana Kane
email: hands@example.gov
password_digest: <%= User.digest('password') %>
malory:
name: Malory Archer
email: boss@example.gov
password_digest: <%= User.digest('password') %>
<% 30.times do |n| %>
user_<%= n %>:
name: <%= "User #{n}" %>
email: <%= "user-#{n}@example.com" %>
password_digest: <%= User.digest('password') %>
<% end %>
統合テストを生成。
$ rails generate integration_test users_index
Running via Spring preloader in process 12447
invoke test_unit
create test/integration/users_index_test.rb test/integration/users_index_test.rb
ページネーションを含めたUsersIndexのテスト内容を記述。
具体的には、
1. Michael(何かのユーザー)でログイン
2. ユーザーのindexページへ移動(テンプレート)
3. ページネーションクラスがあるか
4. ユーザーの名前(変数user)をクリックするとそのprofileページに行くか
require 'test_helper'
class UsersIndexTest < ActionDispatch::IntegrationTest
def setup
@user = users(:michael)
end
test "index including pagination" do
log_in_as(@user)
get users_path
assert_template 'users/index'
assert_select 'div.pagination'
User.paginate(page: 1).each do |user|
assert_select 'a[href=?]', user_path(user), text: user.name
end
end
end
テストは通過。
10.3.5 パーシャルのリファクタリング
いくつかリファクタリングを行う。
リファクタリングの第一歩は、ユーザーのliをrender呼び出しに置き換える。(app/views/users/index.html.erb)
renderをパーシャル (ファイル名の文字列) に対してではなく、Userクラスのuser変数に対して実行している。これは、renderにモデルのインスタンスオブジェクトを渡したときのデフォルトの挙動。この場合、Railsは自動的に_user.html.erbという名前のパーシャルを探しにいくので、各ユーザーを表示するパーシャルを作成する。
<ul class="users">
<% @users.each do |user| %>
<%= render user %>
<!-- => app/views/リソース名/_モデル名.html.erb-->
<!-- => app/views/users/_user.html.erb-->
<% end %>
</ul>
各ユーザーを表示するパーシャル
app/views/users/_user.html.erb
<li>
<%= gravatar_for user, size: 50 %>
<%= link_to user.name, user %>
</li>
今度はrenderを@users変数にして、最終的に下記に。
<ul class="users">
<%= render @users %>
</ul>
Railsは@usersをUserオブジェクトのリストであると推測する。さらに、ユーザーのコレクションを与えて呼び出すと、Railsは自動的にユーザーのコレクションを列挙し、それぞれのユーザーを_user.html.erbパーシャルで出力するので、each文がなくなりコードは短くなった。
一応テストして通過。
10.4 ユーザーを削除する
destroy
の実装。この節では、ユーザーを削除するためのリンクを追加する。もちろん、ユーザーを削除(delete)できるのは管理権限を持ったユーザーのみ。
モックアップは以下の形式。(公式より参考)
ただしその前に、削除を実行できる権限を持つ管理 (admin) ユーザーのクラスを作成する。
10.4.1 管理ユーザー
特権を持つ管理ユーザーを識別するために、論理値をとるadmin属性をUserモデルに追加する。
こうすると自動的にadmin?メソッド (論理値booleanを返す) も使えるようになるため、これを使って管理ユーザーの状態をテストする。
変更後のデータモデルは以下(公式より参考)
まずはマイグレーションを実行してadmin属性を追加(属性の型をbooleanに指定)
$ rails generate migration add_admin_to_users admin:boolean
Running via Spring preloader in process 6078
invoke active_record
create db/migrate/20200120090448_add_admin_to_users.rb
マイグレーションを実行するとadminカラムがusersテーブルに追加される。デフォルトでは管理者になれないことを示す+nilが入るケースを防ぐため、default: false引数
を与える。
class AddAdminToUsers < ActiveRecord::Migration[5.1]
def change
add_column :users, :admin, :boolean, default: false
end
end
マイグレーションを実行。
$ rails db:migrate
コンソール(sandbox)で動作を確認すると、期待どおりadmin属性が追加されて論理値をとり、さらに疑問符の付いたadmin?メソッドも利用できるようになっている。
$ rails console --sandbox
> user = User.first
> user.admin?
=> false
> user.toggle!(:admin)
=> true
> user.admin?
=> true
ここではtoggle!メソッドを使って admin属性の状態をfalseからtrueに反転している。
toggle!メソッドの「!」は破壊的メソッドで、「書き換えたらもう元には戻らない」ことを示している。
演習用として、最初のユーザーだけをデフォルトで管理者にするよう(admin→true)、サンプルデータを更新しておく。
User.create!(name: "Example User",
email: "example@railstutorial.org",
password: "foobar",
password_confirmation: "foobar",
admin: true)
99.times do |n|
name = Faker::Name.name
email = "example-#{n+1}@railstutorial.org"
password = "password"
User.create!(name: name,
email: email,
password: password,
password_confirmation: password)
end
データベースをリセットして、サンプルデータを再度生成。
$ rails db:migrate:reset
$ rails db:seed
10.4.2 destroyアクション
まず、destroyアクションへのリンクを追加する。ユーザーindexページの各ユーザーに削除用のリンクを追加+管理ユーザーへのアクセスを制限が目標。
ユーザー削除用リンクの実装 (管理者にのみ表示される)
(app/views/users/_user.html.erb)
※admin権限を持っていても、自分自身は消せないように && !current_user? で確認を取っている。
<li>
<%= gravatar_for user, size: 50 %>
<%= link_to user.name, user %>
<% if current_user.admin? && !current_user?(user) %>
| <%= link_to "delete", user, method: :delete,
data: { confirm: "You sure?" } %>
<% end %>
</li>
実際にユーザExample Userでログインしてみると、アクションまで(エラー画面で)確認できる。
実際に動作するdestroyアクションを追加する。このアクションでは、該当するユーザーを見つけてActive Recordのdestroyメソッドを使って削除し、最後にユーザーのindexページにリダイレクトさせる。ユーザーを削除するためにはログインしていなくてはならないため、destroyアクションもlogged_in_userフィルター(before_action)に追加している。
ただしこれでは、コマンドラインでDELETEリクエストを直接発行するという方法でサイトの全ユーザーを削除される可能性があるため、destroyアクションにもadmin_userフィルターを入れてアクセス制御を実装する。
class UsersController < ApplicationController
before_action :logged_in_user, only: [:index, :edit, :update, :destroy]
before_action :correct_user, only: [:edit, :update]
before_action :admin_user, only: [:destroy]
省略
# DELETE /users/:id
def destroy
User.find(params[:id]).destroy
flash[:success] = "User deleted"
redirect_to users_url
end
private
# 管理者かどうか確認
def admin_user
redirect_to(root_url) unless current_user.admin?
end
end
10.4.3 ユーザー削除のテスト
fixtureファイルの一番上(Michael)を管理者にする。
michael:
name: Michael Example
email: michael@example.com
password_digest: <%= User.digest('password') %>
admin: true
管理者権限の制御をアクションレベルでテストする。
「ユーザーがログインしてないときにDELETEリクエスト送ったらだめ」
「ログインしていたとしても、adminじゃなかったらやはりだめ」
という内容。
test "should redirect destroy when not logged in" do
assert_no_difference 'User.count' do
delete user_path(@user)
end
assert_redirected_to login_url
end
test "should redirect destroy when logged in as a non-admin" do
log_in_as(@other_user)
assert_no_difference 'User.count' do
delete user_path(@user)
end
assert_redirected_to root_url
end
テストは通過。
最後に、削除リンクとユーザー削除に対する統合テストとして「ユーザーを削除したらユーザーの総数が1つ消えてるよ?」というテストを付け加える(先のテストを大幅に改造)。
上のテストは、
1. サンプルとしてMichaelさん(admin)、Archerさん(non_admin)のユーザーデータを持ってくる
2. ログイン(ユーザーパスが見えるはず)
3. ページネーション見える
4. ユーザーがadminかどうかチェック(adminならdeleteが見えるはず)
5. 選択すればArcherさん(non_admin)は消えるはず
下のテストは,
1. non_adminとしてログイン
2. deleteリンクは見えないはずなのでcountは0か?
require 'test_helper'
class UsersIndexTest < ActionDispatch::IntegrationTest
def setup
@admin = users(:michael)
@non_admin = users(:archer)
end
test "index as admin including pagination and delete links" do
log_in_as(@admin)
get users_path
assert_template 'users/index'
assert_select 'div.pagination'
first_page_of_users = User.paginate(page: 1)
first_page_of_users.each do |user|
assert_select 'a[href=?]', user_path(user), text: user.name
unless user == @admin
assert_select 'a[href=?]', user_path(user), text: 'delete'
end
end
assert_difference 'User.count', -1 do
delete user_path(@non_admin)
end
end
test "index as non-admin" do
log_in_as(@non_admin)
get users_path
assert_select 'a', text: 'delete', count: 0
end
end
テストは通過。
最後にherokuへデプロイ。
$ git add -A
$ git commit -m "Finish ch10"
$ git checkout master
$ git merge updating-users
$ git push heroku master
本番環境として
・ DBリセットは危険なので本来あまりやらない
・ 本番環境にrun rails db:seed
で擬似データを送る。これもあまりやらない
・ リモートのリンクのfetchをクリック
$ heroku pg:reset DATABASE
▸ WARNING: Destructive action
▸ To proceed, type sample-app or re-run this command with --confirm sample-app
> sample-app
$ heroku run rails db:migrate
$ heroku run rails db:seed
$ git remote -v
※リンク確認
本番環境でログインしてユーザー削除の確認ができたので終了!