0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

チュートリアル10章

Last updated at Posted at 2019-08-05

ユーザーの更新・表示・削除

edit、update、index、destroyアクションを加え、RESTアクションを完成させます

まずはユーザーを更新する
newアクションと同じようにして、ユーザーを編集するためのeditアクションを作成
つまりPOSTリクエストに応答するcreateの代わりに、PATCHリクエストに応答するupdateアクションを作成します

違いは、ユーザー登録は誰でも実行できますが、ユーザー情報を更新できるのはそのユーザー自身に限られるというとこ

app/controllers/users_controller.rb
.
.
.


これ追加
  def edit
    @user = User.find(params[:id])
  end

  private

    def user_params
      params.require(:user).permit(:name, :email, :password,
                                   :password_confirmation)
    end
end

$ touch app/views/users/edit.html.erb
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_for(@user) do |f| %>
      <%= render 'shared/error_messages' %>

      <%= 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>

error_messagesパーシャルを再利用しています。
Gravatarへのリンクでtarget="_blank"が使われていますが、これを使うとリンク先を新しいタブ (またはウィンドウ) で開くようになる

@user.new_record?がtrueのときにはPOST(new)を、falseのときにはPATCH(edit)を使います。

app/views/layouts/_header.html.erb
 <header class="navbar navbar-fixed-top navbar-inverse">
  <div class="container">
    <%= link_to "sample app", root_path, id: "logo" %>
    <nav>
      <ul class="nav navbar-nav navbar-right">
        <li><%= link_to "Home", root_path %></li>
        <li><%= link_to "Help", help_path %></li>
        <% if logged_in? %>
          <li><%= link_to "Users", '#' %></li>
          <li class="dropdown">
            <a href="#" class="dropdown-toggle" data-toggle="dropdown">
              Account <b class="caret"></b>
            </a>
            <ul class="dropdown-menu">
              <li><%= link_to "Profile", current_user %></li>
              <li><%= link_to "Settings", edit_user_path(current_user) %></li> ←ここ追加
              <li class="divider"></li>
              <li>
                <%= link_to "Log out", logout_path, method: :delete %>
              </li>
            </ul>
          </li>
        <% else %>
          <li><%= link_to "Log in", login_path %></li>
        <% end %>
      </ul>
    </nav>
  </div>
</header>

target="_blank"で新しいページを開くときには、セキュリティ上の小さな問題があります。
サイト移行する際にフィッシングサイトを挟まれる可能性も(Gravatarのように大きなサイトは可能性が低い)
対処法はリンク用のaタグのrel (relationship) 属性に、"noopener"と設定するだけ。

編集の失敗
無効な情報が送信された場合、更新の結果としてfalseが返され、elseに分岐して編集ページをレンダリングします

app/controllers/users_controller.rb
 class UsersController < ApplicationController

  def show
    @user = User.find(params[:id])
  end

  def new
    @user = User.new
  end

  def create
    @user = User.new(user_params)
    if @user.save ここ
      log_in @user
      flash[:success] = "Welcome to the Sample App!"
      redirect_to @user
    else
      render 'new' ←ここ
    end
  end

  def edit
    @user = User.find(params[:id])
  end

  def update
    @user = User.find(params[:id])
    if @user.update_attributes(user_params) ←ここ
      # 更新に成功した場合を扱う。
    else
      render 'edit' ←ここ
    end
  end

  private

    def user_params
      params.require(:user).permit(:name, :email, :password,
                                   :password_confirmation)
    end
end

編集失敗時のテスト

$ rails generate integration_test users_edit
      invoke  test_unit
      create    test/integration/users_edit_test.rb

PATCHリクエストを送るためにpatchメソッドを使っていること。HTTPリクエストを送信するためのメソッド

test/integration/users_edit_test.rb
 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

TDDで編集を成功させる

パスワードとパスワード確認が空であること
ユーザー名やメールアドレスを毎回パスワードを入力するのは不便なので、パスワードを入力せずに更新できるように

test/integration/users_edit_test.rb

  .
  .
  .
  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
end
app/controllers/users_controller.rb
 class UsersController < ApplicationController
  .
  .
  .
  def update
    @user = User.find(params[:id])
    if @user.update_attributes(user_params)
      flash[:success] = "Profile updated" ←ここ
      redirect_to @user ←ここ
    else
      render 'edit'
    end
  end
  .
  .
  .
end

パスワードのバリデーションに対して、空だったときの例外処理を加える必要があります。こういったときに便利なallow_nil: trueというオプションがある

app/models/user.rb
.
.
.
  validates :password, presence: true, length: { minimum: 6 }, allow_nil: true ここ
  .
  .
  .
end

新規ユーザー登録時に空のパスワードが有効になってしまうのかと心配になるかもしれませんがhas_secure_passwordではオブジェクト生成時に存在性を検証するようになっているため、空のパスワード (nil) が新規ユーザー登録時に有効になることはありません。

認可

どのユーザーでも(ログインしていないユーザーでも) ユーザー情報を編集できてしまうので変更できないように制御する

before_actionメソッドを使って何らかの処理が実行される直前に特定のメソッドを実行する

app/controllers/users_controller.rb
 class UsersController < ApplicationController
  before_action :logged_in_user, only: [:edit, :update]
  .
  .
  .
  private
.
.
.

    # beforeアクション

    # ログイン済みユーザーかどうか確認
    def logged_in_user
      unless logged_in?
        flash[:danger] = "Please log in."
        redirect_to login_url
      end
    end
end

unless(~ではない限り)という日本語訳
ifの逆でfalseを返すと実行される

:onlyオプション (ハッシュ) を渡すことで、:editと:updateアクションだけにこのフィルタが適用される

テスト
先にログイン済みにしておく(エラーになるため

test/integration/users_edit_test.rb
require 'test_helper'

class UsersEditTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end

  test "unsuccessful edit" do
    log_in_as(@user) ←ここ
    get edit_user_path(@user)
    .
    .
    .
  end

  test "successful edit" do
    log_in_as(@user) ←ここ
    get edit_user_path(@user)
    .
    .
    .
  end
end

正しいユーザーを要求する

ユーザーの情報が互いに編集できないことを確認するために、サンプルユーザー2人目を追加

test/fixtures/users.yml
.
.
.

archer:
  name: Sterling Archer
  email: duchess@example.gov
  password_digest: <%= User.digest('password') %>
test/controllers/users_controller_test.rb
 require 'test_helper'

class UsersControllerTest < ActionDispatch::IntegrationTest

  def setup
    @user       = users(:michael)
    @other_user = users(:archer)
  end
  .
  .
  .
  test "should redirect edit when logged in as wrong user" do
    log_in_as(@other_user)
    get edit_user_path(@user)
    assert flash.empty?
    assert_redirected_to root_url
  end

  test "should redirect update when logged in as wrong user" do
    log_in_as(@other_user)
    patch user_path(@user), params: { user: { name: @user.name,
                                              email: @user.email } }
    assert flash.empty?
    assert_redirected_to root_url
  end
end
app/controllers/users_controller.rb
.
.
.
    # 正しいユーザーかどうか確認
    def correct_user
      @user = User.find(params[:id])
      redirect_to(root_url) unless current_user?(@user) ここ
    end
end

修正
ユーザーがログインした後にはその編集ページにリダイレクトされるようにするのが望ましい動作です。

編集ページにアクセスし、ログインした後に、(デフォルトのプロフィールページではなく) 編集ページにリダイレクトされているかどうかをチェックするといったテスト

test/integration/users_edit_test.rb

  .
  .
  .
ここを追加
  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
end

リクエスト時点のページをどこかに保存しておき、その場所にリダイレクトさせる必要があり

app/helpers/sessions_helper.rb
 module SessionsHelper
.
.
.

    # beforeアクション

    # ログイン済みユーザーかどうか確認
    def logged_in_user
      unless logged_in?
        store_location ここ追加
        flash[:danger] = "Please log in."
        redirect_to login_url
      end
    end

    # 正しいユーザーかどうか確認
    def correct_user
      @user = User.find(params[:id])
      redirect_to(root_url) unless current_user?(@user)
    end
end
app/controllers/sessions_controller.rb
 class SessionsController < ApplicationController
  .
  .
  .
  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 ←ここ追加
    else
      flash.now[:danger] = 'Invalid email/password combination'
      render 'new'
    end
  end
  .
  .
  .

すべてのユーザーを表示する
すべてのユーザーを一覧表示

indexアクションが正しくリダイレクトするか検証するテスト

test/controllers/users_controller_test.rb
 require 'test_helper'

class UsersControllerTest < ActionDispatch::IntegrationTest

  def setup
    @user       = users(:michael)
    @other_user = users(:archer)
  end

  test "should redirect index when not logged in" do
    get users_path
    assert_redirected_to login_url
  end
  .
  .
  .
end
app/controllers/users_controller.rb
 class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update]
  before_action :correct_user,   only: [:edit, :update]

  def index
   @users = User.all
  end
$ touch app/views/users/index.html.erb
app/views/users/index.html.erb
 <% 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>
app/helpers/users_helper.rb
 module UsersHelper

  # 渡されたユーザーのGravatar画像を返す
  def gravatar_for(user, options = { size: 80 })
    gravatar_id = Digest::MD5::hexdigest(user.email.downcase)
    size = options[:size]
    gravatar_url = "https://secure.gravatar.com/avatar/#{gravatar_id}?s=#{size}"
    image_tag(gravatar_url, alt: user.name, class: "gravatar")
  end
end

引数を追加する

app/assets/stylesheets/custom.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
 <header class="navbar navbar-fixed-top navbar-inverse">
  <div class="container">
    <%= link_to "sample app", root_path, id: "logo" %>
    <nav>
      <ul class="nav navbar-nav navbar-right">
        <li><%= link_to "Home", root_path %></li>
        <li><%= link_to "Help", help_path %></li>
        <% if logged_in? %>
          <li><%= link_to "Users", users_path %></li> ←ここ追加
          <li class="dropdown">
            <a href="#" class="dropdown-toggle" data-toggle="dropdown">
              Account <b class="caret"></b>
            </a>
            <ul class="dropdown-menu">
              <li><%= link_to "Profile", current_user %></li>
              <li><%= link_to "Settings", edit_user_path(current_user) %></li>
              <li class="divider"></li>
              <li>
                <%= link_to "Log out", logout_path, method: :delete %>
              </li>
・
・
・

サンプルのユーザー
GemfileにFaker gemを追加します

source 'https://rubygems.org'

gem 'rails',          '5.1.6'
gem 'bcrypt',         '3.1.12'
gem 'faker',          '1.7.3'
.
.
.
$ bundle install
db/seeds.rb
 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
$ rails db:migrate:reset
$ rails db:seed

失敗したら

$ rake db:drop
$ rake db:create
$ rake db:schema:load
$ rake db:seed

ページネーション

Gemfile
source 'https://rubygems.org'

gem 'rails',                   '5.1.6'
gem 'bcrypt',                  '3.1.12'
gem 'faker',                   '1.7.3'
gem 'will_paginate',           '3.1.6'
gem 'bootstrap-will_paginate', '1.0.0'
.
.
.
$ bundle install
app/views/users/index.html.erb
 <% 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 %> ←ここ
app/controllers/users_controller.rb
 class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update]
  .
  .
  .
  def index
    @users = User.paginate(page: params[:page])
  end

ユーザー一覧のテスト

test/fixtures/users.yml
 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
      invoke  test_unit
      create    test/integration/users_index_test.rb

最初のページにユーザーがいることを確認します

test/integration/users_index_test.rb
 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

パーシャルのリファクタリング

app/views/users/index.html.erb
 <% provide(:title, 'All users') %>
<h1>All users</h1>

<%= will_paginate %>

<ul class="users">
  <% @users.each do |user| %>
    <%= render user %> ←ここ追加
  <% end %>
</ul>

<%= will_paginate %>

_user.html.erbという名前のパーシャルを作成

$ touch app/views/users/_user.html.erb
app/views/users/_user.html.erb
 <li>
  <%= gravatar_for user, size: 50 %>
  <%= link_to user.name, user %>
</li>

Railsは@users をUserオブジェクトのリストであると推測します。
Railsは自動的にユーザーのコレクションを列挙し、それぞれのユーザーを_user.html.erbパーシャルで出力します。

app/views/users/index.html.erb
 <% provide(:title, 'All users') %>
<h1>All users</h1>

<%= will_paginate %>

<ul class="users">
  <%= render @users %>
</ul>

<%= will_paginate %>

ユーザーを削除する
管理ユーザー

admin属性をUserモデルに追加します。自動的にadmin?メソッドも使えるようになります

$ rails generate migration add_admin_to_users admin:boolean

デフォルトでは管理者になれない

db/migrate/[timestamp]_add_admin_to_users.rb
 class AddAdminToUsers < ActiveRecord::Migration[5.0]
  def change
    add_column :users, :admin, :boolean, default: false
  end
end
$ rails db:migrate
db/seeds.rb
 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

※解説(コードは打たなくていい)
patch /users/17?admin=1
攻撃者はこのようにPATCHリクエストを送信してくるが前の章のStrong Parametersを使って対策した
↓のコードでは、許可された属性リストにadminが含まれていない。これにより、任意のユーザーが自分自身にアプリケーションの管理者権限を与えることを防止できる。
決まった属性しか含めないから。

def user_params
      params.require(:user).permit(:name, :email, :password,
                                   :password_confirmation)
    end

destroyアクション

[delete] リンクが表示されるようになります

app/views/users/_user.html.erb
 <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>

destroyアクションを追加する
ユーザーを削除するためにはログインしていなくてはならないので:destroyアクションもlogged_in_userフィルターに追加

app/controllers/users_controller.rb
 class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update, :destroy]
  before_action :correct_user,   only: [:edit, :update]
  .
  .
  .
  def destroy
    User.find(params[:id]).destroy
    flash[:success] = "User deleted"
    redirect_to users_url
  end

  private
  .
  .
  .
end

、コマンドラインでDELETEリクエストを直接発行するという方法でサイトの全ユーザーを削除してしまうことができる
サイトを正しく防衛するには、destroyアクションにもアクセス制御を行う必要があり

app/controllers/users_controller.rb
 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
  .
  .
  .
  private
    .
    .
    .
    # 管理者かどうか確認
    def admin_user
      redirect_to(root_url) unless current_user.admin?
    end
end

ユーザー削除のテスト

test/fixtures/users.yml
 michael:
  name: Michael Example
  email: michael@example.com
  password_digest: <%= User.digest('password') %>
  admin: true ←ここ追加
.
.
.

ログインしていないユーザーであれば、ログイン画面にリダイレクトされることです。もう1つは、ログイン済みではあっても管理者でなければ、ホーム画面にリダイレクトされること

test/controllers/users_controller_test.rb
 require 'test_helper'

class UsersControllerTest < ActionDispatch::IntegrationTest

  def setup
    @user       = users(:michael)
    @other_user = users(:archer)
  end
  .
  .
  .
  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
end

assert_no_differenceメソッド を使って、ユーザー数が変化しないことを確認している

削除リンクとユーザー削除に対する統合テスト

test/integration/users_index_test.rb
 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
git add
commit 
push

後に
$ git push heroku
$ heroku pg:reset DATABASE
$ heroku run rails db:migrate
$ heroku run rails db:seed
$ heroku restart
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?