T_Y_CODE

プログラミング学習記録

学習記録 20日目

20日目の学習記録をまとめていきます。

学習計画

学習内容

Ruby on Railsチュートリアル 11章

  • ユーザの新規作成時にアカウントを有効化するステップを差し込みます。このステップを差し込む事でメールアドレスの本人確認を行う事が出来ます。
11.1 AccountActivationsリソース
  • トピックブランチを作成します。
$ git checkout -b account-activation
11.1.1 AccountActivationsコントローラ
  • まずはAccountActivationsコントローラを作成します。
$ rails g controller AccountActivations
  • ルーティングを設定します。今回はメールにてアカウントを有効化するURLを送信しGETリクエストでパラメータを取得します。よってupdateアクションではなくeditアクションを使用します。
# cofig/routes.rb
Rails.application.routes.draw do
  #...
  resources :account_activations, only: [:edit]
end
演習

1. 現時点でテストスイートを実行すると green になることを確認してみましょう。

$ rails t
42 tests, 180 assertions, 0 failures, 0 errors, 0 skips

2. 表 11.2の名前付きルートでは、_pathではなく_urlを使うように記してあります。なぜでしょうか? 考えてみましょう。

  • メールで有効化URLを送付するため相対パスの_pathではなく絶対パスの_urlを使用している。
11.1.2 AccountActivationのデータモデル
  • アカウントの有効化はメールのURLとデータベース上の文字列を比較する方法があるがデータベースの情報が漏れた場合多大な被害に繋がってしまう。rememberの時同様にトークンをハッシュ化してデータベースに保存する方法を行い対策していく。
  • 必要になるカラムをusersテーブルに追加していきます。まずはマイグレーションを作成します。
$ rails g migration add_activation_to_users activation_digest:string activated:boolean activated_at:datetime
  • activatedカラムのデフォルト値はfalseにします。
# db/***_add_activation_to_users.rb
class AddActivationToUsers < ActiveRecord::Migration[6.0]
  def change
    add_column :users, :activation_digest, :string
    add_column :users, :activated, :boolean, default: false
    add_column :users, :activated_at, :datetime
  end
end
  • マイグレートします。
$ rails db:migrate
  • 次にUserモデルが作成される直前にactivation_digestを作成するメソッドをコールバックを実装していきます。rememberトークン同様にactivationトークンにもゲッタ, セッタを作成しておきます。
# app/models/user.rb
class User < ApplicationRecord
  attr_accessor :remember_token, :activation_token
  before_save :downcase_email
  before_create :create_activation_digest
  
  #...

  private
    def downcase_email
      self.email = email.downcase
    end

    def create_activation_digest
      self.activation_token = User.new_token
      self.activation_digest = User.digest(activation_token)
    end
end
  • seedやfixtureのユーザの有効化をしておきます。
  • データベースをリセットします。
$ rails db:migrate:reset
$ rails db:seed
演習

1. 本項での変更を加えた後、テストスイートが green のままになっていることを確認してみましょう。

$ rails t
42 tests, 180 assertions, 0 failures, 0 errors, 0 skips

2. コンソールからUserクラスのインスタンスを生成し、そのオブジェクトからcreate_activation_digestメソッドを呼び出そうとすると(Privateメソッドなので)NoMethodErrorが発生することを確認してみましょう。また、そのUserオブジェクトからダイジェストの値も確認してみましょう。

$ rails c
> user = User.new
> user.create_activation_digest
NoMethodError (private method `create_activation_digest' called for #<User:0x00007fcb7967c7e8>)

3. リスト 6.35で、メールアドレスの小文字化にはemail.downcase!という(代入せずに済む)メソッドがあることを知りました。このメソッドを使って、リスト 11.3のdowncase_emailメソッドを改良してみてください。また、うまく変更できれば、テストスイートは成功したままになっていることも確認してみてください。

# app/models/user.rb
class User < ApplicationRecord
  #...

  private
    def downcase_email
      email.downcase!
    end

    #...
end
$ rails t
42 tests, 180 assertions, 0 failures, 0 errors, 0 skips
11.2.1 送信メールのテンプレート

ActionMailerライブラリを使用しUserのメイラーを作成します。

$ rails g mailer UserMailer account_activation password_reset
Running via Spring preloader in process 31021
      create  app/mailers/user_mailer.rb
      invoke  erb
      create    app/views/user_mailer
      create    app/views/user_mailer/account_activation.text.erb
      create    app/views/user_mailer/account_activation.html.erb
      create    app/views/user_mailer/password_reset.text.erb
      create    app/views/user_mailer/password_reset.html.erb
      invoke  test_unit
      create    test/mailers/user_mailer_test.rb
      create    test/mailers/previews/user_mailer_preview.rb
  • メイラーとメールのテンプレートファイル, テスト, プレビューが作成されました。
  • メールの送信元アドレスを設定します。
# app/mailers/application_mailer.rb
class ApplicationMailer < ActionMailer::Base
  default from: 'noreply@example.com'
  layout 'mailer'
end
  • user_mailerメイラーのaccount_activationアクションの実装をしていきます。ビューでuserの情報を使用したいのでインスタンス変数@userを作成します。また送信先やメールの件名を設定します。
# app/mailers/user_mailer.rb
class UserMailer < ApplicationMailer

  def account_activation(user)
    @user = user
    mail to: user.email, subject: "Account activation"
  end

  #...
end
  • メールで送信するビューを作成します。URL部はedit_account_activation_url(@user.activated_token, email: @user.email)と記述します。
edit_account_activation_url(@user.activated_token, email: @user.email)
# => /account_activations/__token__/edit?email=foo%40example.com
演習

1. コンソールを開き、CGIモジュールのescapeメソッド(リスト 11.15)でメールアドレスの文字列をエスケープできることを確認してみましょう。このメソッドで"Don't panic!"をエスケープすると、どんな結果になりますか?

$ rails c
> CGI.escape("foo@example.com")
# => "foo%40example.com"
> CGI.escape("Don't panic!")
# => "Don%27t+panic%21"
11.2.2 送信メールのプレビュー
  • ローカル環境のメール設定をします。
# config/environments/development.rb
Rails.application.configure do

  #...

  config.action_mailer.raise_delivery_errors = false

  host = 'localhost:3000'
  config.action_mailer.default_url_options = { host: host, protocol: 'http' }

  config.action_mailer.perform_caching = false

  #...
end
  • メイラープレビューの設定を行います。User.firstでプレビューを行います。
# test/mailers/previews/user_mailer_preview.rb
class UserMailerPreview < ActionMailer::Preview

  def account_activation
    user = User.first
    user.activation_token = User.new_token
    UserMailer.account_activation(user)
  end

  def password_reset
    UserMailer.password_reset
  end

end
  • プレビューで動作確認します。ちゃんと動いてますね。ローカル環境の設定を変えたのでRailsサーバを再起動しないと上手く動きません。

f:id:t_y_code:20201026104352p:plain

演習

1. Railsプレビュー機能を使って、ブラウザから先ほどのメールを表示してみてください。「Date」の欄にはどんな内容が表示されているでしょうか?

  • 現在時刻が表示されています。
11.2.3 送信メールのテスト
  • メイラーのテストを作成します。
# test/mailers/user_mailer_test.rb
require 'test_helper'

class UserMailerTest < ActionMailer::TestCase
  test "account_activation" do
    user = users(:michael)
    user.activation_token = User.new_token
    mail = UserMailer.account_activation(user)
    assert_equal "Account activation", mail.subject
    assert_equal [user.email], mail.to
    assert_equal ["noreply@example.com"], mail.from
    assert_match user.name, mail.body.encoded
    assert_match user.activation_token, mail.body.encoded
    assert_match CGI.escape(user.email), mail.body.encoded
  end
end
  • テストは失敗します。
$ rails test:mailers
ERROR["test_account_activation", #<Minitest::Reporters::Suite:0x00007f88d27bcca0 @name="UserMailerTest">, 0.1705979999999272]
 test_account_activation#UserMailerTest (0.17s)
ActionView::Template::Error:         ActionView::Template::Error: Missing host to link to! Please provide the :host parameter, set default_url_options[:host], or set :only_path to true
            app/views/user_mailer/account_activation.html.erb:9
            app/mailers/user_mailer.rb:5:in `account_activation'
            test/mailers/user_mailer_test.rb:8:in `block in <class:UserMailerTest>'
1 tests, 0 assertions, 0 failures, 1 errors, 0 skips
  • default_url_options[:host]を設定してとエラーメッセージが出ているので設定します。
# config/environments/test.rb
Rails.application.configure do
  #...

  config.action_mailer.default_url_options = { host: 'example.com' }

  #...
end
  • テストが成功することを確認します。
$ rails test:mailers
1 tests, 9 assertions, 0 failures, 0 errors, 0 skips
演習

1. この時点で、テストスイートが green になっていることを確認してみましょう。

$ rails t
43 tests, 189 assertions, 0 failures, 0 errors, 0 skips

2. リスト 11.20で使ったCGI.escapeの部分を削除すると、テストが red に変わることを確認してみましょう。

# test/mailers/user_mailer_test.rb
require 'test_helper'

class UserMailerTest < ActionMailer::TestCase
  test "account_activation" do
    #...
    assert_match user.email, mail.body.encoded
  end
end
$ rails test:mailers
1 tests, 9 assertions, 1 failures, 0 errors, 0 skips
11.2.4 ユーザーのcreateアクションを更新
  • Userコントローラcreateアクションにてメイラーアクションを実行します。deliver_nowメソッドでメールの送信を行います。
# app/controllers/users_controller.rb
class UsersController < ApplicationController
  #...

  def create
    @user = User.new(user_params)
    if @user.save
      UserMailer.account_activation(@user).deliver_now
      flash[:info] = "Please check your email to activate your account."
      redirect_to root_url
    else
      render 'new'
    end
  end

  #...
end
  • リダイレクト先をroot_urlへ変更したため一部テストが失敗するようになる。一時的にコメントアウトしてテストが成功するようにする。
$ rails t
FAIL["test_valid_signup_information", #<Minitest::Reporters::Suite:0x00007fbc270d0df0 @name="UsersSignupTest">, 2.888349000000062]
 test_valid_signup_information#UsersSignupTest (2.89s)
        expecting <"users/show"> but rendering with <["user_mailer/account_activation", "layouts/mailer", "static_pages/home", "layouts/_rails_default", "layouts/_shim", "layouts/_header", "layouts/_footer", "layouts/application"]>
        test/integration/users_signup_test.rb:26:in `block in <class:UsersSignupTest>'
43 tests, 187 assertions, 1 failures, 0 errors, 0 skips
# test/integration/users_signup_test.rb
require 'test_helper'

class UsersSignupTest < ActionDispatch::IntegrationTest
  #...

  test "valid signup information" do
    get signup_path
    assert_difference 'User.count', 1 do
      post users_path, params: { user: {  name: "Example User",
                                          email: "user@example.com",
                                          password: "foobar",
                                          password_confirmation: "foobar" }}
    end
    follow_redirect!
    # assert_template 'users/show'
    # assert_not flash.empty?
    # assert is_logged_in?
  end
end
$ rails t
43 tests, 186 assertions, 0 failures, 0 errors, 0 skips
演習

1. 新しいユーザーを登録したとき、リダイレクト先が適切なURLに変わったことを確認してみましょう。その後、Railsサーバーのログから送信メールの内容を確認してみてください。有効化トークンの値はどうなっていますか?

  • root_urlにリダイレクトされサーバログに送信内容が記載されています。

f:id:t_y_code:20201026110809p:plain
f:id:t_y_code:20201026110822p:plain
2. コンソールを開き、データベース上にユーザーが作成されたことを確認してみましょう。また、このユーザーはデータベース上にはいますが、有効化のステータスがfalseのままになっていることを確認してください。

$ rails c
> user = User.last
> user
# => #<User id: 101, name: "hogehoge", email: "foo@bar.com", created_at: "2020-10-26 02:06:55", updated_at: "2020-10-26 02:06:55", password_digest: [FILTERED], remember_digest: nil, admin: false, activation_digest: "$2a$12$1gMU2ovlg9EEmzZRcOJ0CePlvkkYLMLWLG8MYIGl1N4...", activated: false, activated_at: nil>
authenticated?メソッドの抽象化
  • 現在のauthenicated?メソッドはremember_digestに特化しているためそのままではactivation_digestには使用出来ない。Rubyのsendメソッドを使用することでauthenicated?メソッドを抽象化しactivation_digestにも使用出来るようにする。
# app/models/user.br
class User < ApplicationRecord
  #...

  def authenticated?(attribute, token)
    digest = send("#{attribute}_digest")
    return false if digest.nil?
    BCrypt::Password.new(digest).is_password?(token)
  end

  #...
end
  • テストをしてみると失敗します。
$ rails t
ERROR["test_authenicated?_shold_return_false_for_a_user_with_nil_digest", #<Minitest::Reporters::Suite:0x00007fbc23323520 @name="UsersLoginTest">, 2.3825440000000526]
 test_authenicated?_shold_return_false_for_a_user_with_nil_digest#UsersLoginTest (2.38s)
ArgumentError:         ArgumentError: wrong number of arguments (given 1, expected 2)
            app/models/user.rb:26:in `authenticated?'
            test/integration/users_login_test.rb:54:in `block in <class:UsersLoginTest>'

ERROR["test_current_user_returns_nil_when_remember_digest_is_wrong", #<Minitest::Reporters::Suite:0x00007fbc22997e48 @name="SessionsHelperTest">, 2.5347179999998843]
 test_current_user_returns_nil_when_remember_digest_is_wrong#SessionsHelperTest (2.53s)
ArgumentError:         ArgumentError: wrong number of arguments (given 1, expected 2)
            app/models/user.rb:26:in `authenticated?'
            app/helpers/sessions_helper.rb:11:in `current_user'
            test/helpers/sessions_helper_test.rb:17:in `block in <class:SessionsHelperTest>'

ERROR["test_current_user_returns_right_user_when_session_is_nil", #<Minitest::Reporters::Suite:0x00007fbc229491d0 @name="SessionsHelperTest">, 2.550060999999914]
 test_current_user_returns_right_user_when_session_is_nil#SessionsHelperTest (2.55s)
ArgumentError:         ArgumentError: wrong number of arguments (given 1, expected 2)
            app/models/user.rb:26:in `authenticated?'
            app/helpers/sessions_helper.rb:11:in `current_user'
            test/helpers/sessions_helper_test.rb:11:in `block in <class:SessionsHelperTest>'
43 tests, 182 assertions, 0 failures, 3 errors, 0 skips
  • authenticated?メソッドを変更し引数にattributeを追加したために起きたエラーです。authenticated?メソッドを使用している箇所を修正します。
# app/helpers/sessions_helper.rb
module SessionsHelper
  #...

  def current_user
    if (user_id = session[:user_id])
      @current_user ||= User.find_by(id: user_id)
    elsif (user_id = cookies.signed[:user_id])
      user = User.find_by(id: user_id)
      if user&.authenticated?(:remember, cookies[:remember_token])
        log_in user
        @current_user = user
      end
    end
  end

  #...
end
# test/integration/users_login_test.rb
require 'test_helper'

class UsersLoginTest < ActionDispatch::IntegrationTest
  #...

  test "authenicated? shold return false for a user with nil digest" do
    assert_not @user.authenticated?(:remember, '')
  end

  #...
end
$ rails t
43 tests, 186 assertions, 0 failures, 0 errors, 0 skips
  • 無事テストが通過しました。
演習

1. コンソール内で新しいユーザーを作成してみてください。新しいユーザーの記憶トークンと有効化トークンはどのような値になっているでしょうか? また、各トークンに対応するダイジェストの値はどうなっているでしょうか?

  • remember_token, remember_digestの作成はrememberメソッド実行時に行われる。rememberメソッドはログイン時のチェックボックスがチェックされた際に実行されるためUserの新規作成だけだとnilになる。
$ rails c
> user = User.create(name: "Foo Bar", email: "foo@bar.org", password: "foobar", password_confirmation: "foobar")
> user.activation_token
# => "8fMEdsgTaYRzJKByl4LVRg"
> user.activation_digest
# => "$2a$12$2.CmyJwWcUIRtGBV4/rZOuwbuHSaMyWOX0haHdvsfESN3XvZGoxWO"
> user.remember_token
# => nil
> user.remember_digest
# => nil

2. リスト 11.26で抽象化したauthenticated?メソッドを使って、先ほどの各トークン/ダイジェストの組み合わせで認証が成功することを確認してみましょう。

  • やっぱりnilだと通らないですね。rememberメソッドを実行してからだと通るようになります。
> user.authenticated?(:activation, user.activation_token)
# => true
> user.authenticated?(:remember, user.remember_token)
> user.remember
# => true
> user.remember_token
# => "SFEzXdHBv3GukTxR8QwMzQ"
> user.remember_digest
# => "$2a$12$a3F9MiPMe2cdLIBJCXUUveRf8MnWQc6wQh69tjYajXRBgi0ulm3Oe"
> user.authenticated?(:remember, user.remember_token)
# => true
11.3.2 editアクションで有効化
  • AccountActivationsコントローラのeditアクションを書いていきます。
# app/controllers/account_activations_controller.rb
class AccountActivationsController < ApplicationController
  def edit
    user = User.find_by(email: params[:email])
    if user && !user.activated? && user.authenticated?(:activation, params[:id])
      user.update_attribute(:activated, true)
      user.update_attribute(:activated_at, Time.zone.now)
      log_in user
      flash[:success] = "Account activated!"
      redirect_to user
    else
      flash[:danger] = "Invalid activation link"
      redirect_to root_url
    end
  end
end
  • 動作確認をしてみます。ちゃんと動作しました。

f:id:t_y_code:20201026120257p:plain

  • 有効化されてないユーザがログインしようとしてもログイン出来ないようにします。
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  #...

  def create
    @user = User.find_by(email: params[:session][:email].downcase)
    if @user&.authenticate(params[:session][:password])
      if @user.activated?
        log_in @user
        params[:session][:remember_me] == '1' ? remember(@user) : forget(@user)
        redirect_back_or @user
      else
        message = "Account not acticated."
        message += "Check your email for the activation link."
        flash[:warning] = message
        redirect_to root_url
      end
    else
      flash.now[:danger] = "Invalid email/password combination" 
      render 'new'
    end
  end

  #...
end
  • 動作確認します。

f:id:t_y_code:20201026120735p:plain

演習

1. コンソールから、11.2.4で生成したメールに含まれているURLを調べてみてください。URL内のどこに有効化トークンが含まれているでしょうか?

# URL
http://localhost:3000/account_activations/t8k1wzzpYqxopAPlXFmUlw/edit?email=hogehoge%40hoge.com
# 有効化トークン
t8k1wzzpYqxopAPlXFmUlw

2. 先ほど見つけたURLをブラウザに貼り付けて、そのユーザーの認証に成功し、有効化できることを確認してみましょう。また、有効化ステータスがtrueになっていることをコンソールから確認してみてください。

$ rails c
> User.last.activated
# => true
11.3.3 有効化のテストとリファクタリング
# test/integration/users_signuo_test.rb
require 'test_helper'

class UsersSignupTest < ActionDispatch::IntegrationTest
  def setup
    ActionMailer::Base.deliveries.clear
  end

  #...

  test "valid signup information with account activation" do
    get signup_path
    assert_difference 'User.count', 1 do
      post users_path, params: { user: {  name: "Example User",
                                          email: "user@example.com",
                                          password: "foobar",
                                          password_confirmation: "foobar" }}
    end
    assert_equal 1, ActionMailer::Base.deliveries.size
    user = assigns(:user)
    assert_not user.activated?
    log_in_as(user)
    assert_not is_logged_in?
    get edit_account_activation_path("invalid token", email: user.email)
    assert_not is_logged_in?
    get edit_account_activation_path(user.activation_token, email: "wrong")
    assert_not is_logged_in?
    get edit_account_activation_path(user.activation_token, email: user.email)
    assert user.reload.activated?
    follow_redirect!
    assert_template 'users/show'
    assert is_logged_in?
  end
end
  • テストが成功することを確認します。
$ rails t
43 tests, 194 assertions, 0 failures, 0 errors, 0 skips
# app/models/user.rb
class User < ApplicationRecord
  #...

  def send_activation_email
    UserMailer.account_activation(self).deliver_now
  end

  def activate
    update_attribute(:activated, true)
    update_attribute(:activated_at, Time.zone.now)
  end

  #...
end
# app/controllers/user_controller.rb
class UsersController < ApplicationController
  #...

  def create
    @user = User.new(user_params)
    if @user.save
      @user.send_activation_email
      flash[:info] = "Please check your email to activate your account."
      redirect_to root_url
    else
      render 'new'
    end
  end

  #...
end
# app/controllers/account_activations_controller.rb
class AccountActivationsController < ApplicationController
  def edit
    user = User.find_by(email: params[:email])
    if user && !user.activated? && user.authenticated?(:activation, params[:id])
      user.activate
      log_in user
      flash[:success] = "Account activated!"
      redirect_to user
    else
      flash[:danger] = "Invalid activation link"
      redirect_to root_url
    end
  end
end
$ rails t
43 tests, 194 assertions, 0 failures, 0 errors, 0 skips
演習

1. リスト 11.35にあるactivateメソッドはupdate_attributeを2回呼び出していますが、これは各行で1回ずつデータベースへ問い合わせしていることになります。リスト 11.39に記したテンプレートを使って、update_attributeの呼び出しを1回のupdate_columns呼び出しにまとめてみましょう。これでデータベースへの問い合わせが1回で済むようになります(注意!update_columnsは、モデルのコールバックやバリデーションが実行されない点がupdate_attributeと異なります)。また、変更後にテストを実行し、 green になることも確認してください。

# app/models/user.rb
class User < ApplicationRecord
  #...

  def activate
    update_columns(activated: true, activated_at: Time.zone.now)
  end

  #...
end
$ rails t
43 tests, 194 assertions, 0 failures, 0 errors, 0 skips

2. 現在は、/usersのユーザーindexページを開くとすべてのユーザーが表示され、/users/:idのようにIDを指定すると個別のユーザーを表示できます。しかし考えてみれば、有効でないユーザーは表示する意味がありません。そこで、リスト 11.40のテンプレートを使って、この動作を変更してみましょう9 。なお、ここで使っているActive Recordのwhereメソッドについては、13.3.3でもう少し詳しく説明します。

# app/controllers/users_controller.rb
class UsersController < ApplicationController
  #...

  def index
    @users = User.where(activated: true).paginate(page: params[:page])
  end

  #...

  def show
    @user = User.find(params[:id])
    redirect_to root_url and return unless @user.activated?
  end

  #...
end

3. ここまでの演習課題で変更したコードをテストするために、/users と /users/:id の両方に対する統合テストを作成してみましょう。

# test/integration/users_index_test.rb
require 'test_helper'

class UsersIndexTest < ActionDispatch::IntegrationTest
  #...

  test "index shouldn't include not activated account" do
    log_in_as(@admin)
    post users_path, params: { user: {  name: "test account",
                                        email: "test@test.com",
                                        password: "foobar",
                                        password_confirmation: "foobar" } }
    non_active_user = User.last
    last_page_num = ( User.count / 30 ).ceil
    get users_path(page: last_page_num)
    assert_select 'a[href=?]', user_path(non_active_user), count: 0
  end

  test "should redirect with not activated account show page" do
    post users_path, params: { user: {  name: "Foo Bar",
                                        email: "foo@bar.com",
                                        password: "foobar",
                                        password_confirmation: "foobar" } }
    non_active_user = User.last
    get user_path(non_active_user)
    assert_redirected_to root_url
  end
end
$ rails t
45 tests, 196 assertions, 0 failures, 0 errors, 0 skips
11.4 本番環境でのメール送信
  • herokuのSendGridアドオンを使用して本番環境でメール送信出来るようにします。設定ファイルに追記を行います。
  • 本番環境で確認するためにデプロイを行います。
$ rails t
$ git add -A
$ git commit -m "Add account activation"
$ git push origin account-activation
$ git checkout master
$ git merge account-activation
$ git push origin master
$ git push heroku
$ heroku run rails db:migrate
  • herokuにSendGridアドオンを追加します。
$ heroku addons:create sendgrid:starter
  • 動作確認しましたが1周目でも出たSendGrid凍結問題が発生。メールの受信確認はせずにherokuのlogsコマンドからURLを見つけ動作確認します。(herokuのアプリを作り直せば凍結は一時的に治るがまたすぐ凍結されるため今回は諦めておきます)
$ heroku logs
2020-10-26T04:38:38.180809+00:00 app[web.1]: [0adb71bd-ab75-4e1f-b785-fb1a41b61343] Net::SMTPAuthenticationError (535 Authentication failed: account disabled
  • 有効化まで確認しました。

f:id:t_y_code:20201026134404p:plain

  • 以上で11章は終了です。

Ruby on Rails 5 速習実践ガイド 4章

4-2-2 NOT NULL制約
  • カラムの値にNULLを格納する必要が無い場合はNOT NULL制約を付けることでNULLを入れないよう制限出来る。
  • Taskleafアプリのnameカラムの中身がNULLの場合リンクが表示されず不便のためNOT NULL制約を付けていく。
$ git checkout -b chapter-4-2
$ rails g migration ChangeTasksNameNotNull
# db/migrate/***_chage_tasks_name_not_null.rb
class ChangeTasksNameNotNull < ActiveRecord::Migration[6.0]
  def change
    chage_column_null :tasks, :name, false
  end
end
$ rails db:migrate
  • 試しにNULLを入れてみます。
$ rails c
> Task.new(name: nil).save
ActiveRecord::NotNullViolation (PG::NotNullViolation: ERROR:  null value in column "name" of relation "tasks" violates not-null constraint)
4-2-3 文字列カラムの長さを指定する
  • マイグレーションファイルはバージョンを上げる処理だけでなく下げる処理も書く必要がある。add_***メソッドはdown処理を書かなくてもRailsが自動でdown処理をしてくれるがchange_***メソッドはdown処理を書かないといけない。マイグレートする時は下げる処理も確認するためにrails db:migrate:redoを実行したほうが良いかもれしれない。
4-2-4 ユニークインデックスを作成する
  • ユニークインデックスを作成することで重複を防ぐ事が出来る。
4-3-3 必須かどうかの検証を追加する
  • Taskモデルにバリデーションを追加します。persisted?メソッドはデータベース上に保存されたかを確認するメソッドです。
# app/models/task.rb
class Task < ApplicationRecord
  validates :name, presence: true
end
$ rails c
> task = Task.new
> task.save
# => false
> task.errors.full_messages
# => ["Nameを入力してください"]
> task.persisted?
# => false
> task = Task.new(name: "テストタスク")
> task.save
# => true
> task.persisted?
# => true
4-3-4 コントローラとビューで検証エラーに対応する
  • createアクションでは現状save!メソッドを使用している。saveメソッドへ変更しタスクが検証エラー担った場合newビューをレンダリングするよう変更する。またnewビューはレンダリングするだけでアクションは実行されてないのでtaskをインスタンス変数化してビューで使用出来るようにする必要がある。
# app/controllers/tasks_controller.rb
class TasksController < ApplicationController
  #...

  def create
    @task = Task.new(task_params)
    if @task.save
      redirect_to tasks_url, notice: "タスク「#{@task.name}」を登録しました。"
    else
      render :new
    end
  end

  #...
end
  • formパーシャルにエラーメッセージを表示する処理を実装します。
# app/views/tasks/_form.html.slim
#...

- if task.errors.present?
  ul#error_explanation
    - task.errors.full_messages.each do |message|
      li= message

= form_with model: task, local: true do |f|
  #...
4-3-5 文字列長の検証を追加する
  • nameの文字列を30文字以内に制限する。
# app/models/task.rb
class Task < ApplicationRecord
  validates :name, presence: true, length: { maximum: 30 }
end
4-3-6 オリジナルの検証コードを書く
  • before_actionのようにvalidateはメソッドを実行してチェック出来る。以下はnameカラムにカンマの含む文字列には制限を書ける例である。
# app/models/task.rb
class Task < ApplicationRecord
  validates :name, presence: true, length: { maximum: 30 }
  validate :validate_name_not_including_comma

  def validate_name_not_including_comma
    errors.add(:name, 'にカンマを含めることはできません') if name&.include?(',')
  end
end
$ rails c
> task = Task.new
> task.name = "タスク,テスト"
> task.save
# => false
> task.errors.full_messages
# => ["Nameにカンマを含めることはできません"]
4-5-2 Userモデルを作る
  • bcrypt gemを使用してハッシュ化したpasswordをデータベース上に保存します。まずはname, email, password_digestを持ったUserモデルを作成します。
$ rails g model User name:string email:string password_digest:string
  • マイグレーションを編集してNOT NULL制約を追加します。またemailは一意性を保つためにインデックスを作成しunique: trueする。
# db/migrate/***_create_users.rb
class CreateUsers < ActiveRecord::Migration[6.0]
  def change
    create_table :users do |t|
      t.string :name, null: false
      t.string :email, null: false
      t.string :password_digest, null: false

      t.timestamps
      t.index :email, unique: true
    end
  end
end
  • マイグレートします。
$ rails db:migrate
  • bcrypt gemを追加します。
  • has_secure_passwordが使用出来るようになりました。これでUserモデルはpasswordとpassword_confirmationの2つの仮想属性を持てるようになりました。
# app/models/user.rb
class User < ApplicationRecord
  has_secure_password
end
  • railsコンソールで動作確認してみます。password_digestにはハッシュ化された文字列が格納されています。
$ rails c --sandbox
> user = User.new(name: "ユーザー", email: "sample@example.com", password: "foobar", password_confirmation: "foobar")
> user.password_digest
# => "$2a$12$31CEhg2yb7qMszwPPUOiQ.sYfgfiCShN0EEBy2pj3c4gHzUj15aU."
4-5-4-1 Userモデルにadminフラグを追加する
  • ユーザが管理者かどうかを表すフラグを追加します。
$ rails g migration add_admin_to_users
# db/migrate/***_add_admin_to_users.rb
class AddAdminToUsers < ActiveRecord::Migration[6.0]
  def change
    add_column :users, :admin, :boolean, default: false, null: false
  end
end
$ rails db:migrate
4-5-4-2 ユーザ管理のためのコントローラを実装する
  • Admin::UserControllerコントローラを作成します。これはAdminモジュールの名前空間にUserControllerクラスを作成するという意味になります。Railsではモジュール階層がディレクトリ階層に対応しておりコマンドを実行するとapp/controllers/admin/users_controller.rbが作成される。new, edit, show, indexビューを作成します。
$ rails g controller Admin::Users new edit show index
  • ルーティングを書いていきます。
# config/routes.rb
Rails.application.routes.draw do
  namespace :admin do
    resources :users
  end
  root 'tasks#index'
  resources :tasks
end
  • new, createアクション, newビューを作成します。
# app/controllers/admin/users_controller.rb
class Admin::UsersController < ApplicationController
  def new
    @user = User.new
  end

  def create
    @user = User.new(user_params)

    if @user.save
      redirect_to admin_user_url(@user), notice: "ユーザー「#{@user.name}」を登録しました。"
    else
      render :new
    end
  end

  #...

  private
    def user_params
      params.require(:user).permit(:name, :email, :admin, :password, :password_confirmation)
    end
end
# app/views/admin/users/new.html.slim
h1 ユーザー登録

= form_with model: [:admin, @user], local: true do |f|
  .form-group
    = f.label :name, '名前'
    = f.text_field :name, class: 'form-control'
  .form-group
    = f.label :email, 'メールアドレス'
    = f.email_field :email, class: 'form-control'
  .form-check
    = f.label :admin, class: 'form-check-label' do
      = f.check_box :admin, class: 'form-check-input'
      | 管理者権限
  .form-group
    = f.label :password, 'パスワード'
    = f.password_field :password, class: 'form-control'
  .form-group
    = f.label :password_confirmation, 'パスワード(確認用)'
    = f.password_field :password_confirmation, class: 'form-control'
  = f.submit '登録する', class: 'btn btn-primary'
  • Userクラスの検証を追記します。
# app/models/user.rb
class User < ApplicationRecord
  has_secure_password

  validates :name, presence: true
  validates :email, presence: true, uniqueness: true
end
  • 動作確認をします。

f:id:t_y_code:20201026153331p:plain

  • showアクション, ビューを作成します。
# app/controllers/admin/users_controller.rb
class Admin::UsersController < ApplicationController
  #...

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

  #...
end
# app/views/admin/users/show.html.slim
h1 ユーザーの詳細

.nav.justify-content-end
  = link_to '一覧', admin_users_path, class: 'nav-link'
table.table.table-hover
  tbody
    tr
      th= User.human_attribute_name(:id)
      td= @user.id
    tr
      th= User.human_attribute_name(:name)
      td= @user.name
    tr
      th= User.human_attribute_name(:email)
      td= @user.email
    tr
      th= User.human_attribute_name(:admin)
      td= @user.admin? ? 'あり' : 'なし'
    tr
      th= User.human_attribute_name(:created_at)
      td= @user.created_at
    tr
      th= User.human_attribute_name(:updated_at)
      td= @user.updated_at
    
= link_to '編集', edit_admin_user_path, class: 'btn btn-primary mr-3'
= link_to '削除', [:admin, @user], method: :delete, data: { confirm: "ユーザー「#{@user.name}」を削除します。よろしいですか?" }, class: 'btn btn-danger'
  • 動作確認します。ユーザの作成が出来ました。

f:id:t_y_code:20201026155245p:plain

  • 次にユーザーの一覧ページを作っていきます。
# app/controllers/admin/users_controller.rb
class Admin::UsersController < ApplicationController
  #...

  def index
    @users = User.all
  end

  #...
end
# app/views/admin/users/index.html.slim
h1 ユーザー一覧

= link_to '新規登録', new_admin_user_path, class: 'btn btn-primary'

.mb-3
table.table.table-hover
  thead.thead-default
    tr
      th= User.human_attribute_name(:name)
      th= User.human_attribute_name(:email)
      th= User.human_attribute_name(:admin)
      th= User.human_attribute_name(:created_at)
      th= User.human_attribute_name(:updated_at)
    th
  tbody
    - @users.each do |user|
      tr
        td= link_to user.name, [:admin, user]
        td= user.email
        td= user.admin? ? 'あり' : 'なし'
        td= user.created_at
        td= user.updated_at
        td
          = link_to '編集', edit_admin_user_path(user), class: 'btn btn-primary mr-3'
          = link_to '削除', [:admin, user], method: :delete, data: { confirm: "ユーザー「#{user.name}」を削除します。よろしいですか?" }, class: 'btn btn-danger'
  • 動作確認をします。

f:id:t_y_code:20201026160026p:plain

  • 先ほどnewで作成したform部分をパーシャルします。エラーメッセージが表示されるようにしておきます。
# app/views/admin/users/_form.html.slim
- if user.errors.present?
  ul#error_explanation
    - user.errors.full_messages.each do |message|
      li= message

= form_with model: [:admin, user], local: true do |f|
  .form-group
    = f.label :name, '名前'
    = f.text_field :name, class: 'form-control'
  .form-group
    = f.label :email, 'メールアドレス'
    = f.email_field :email, class: 'form-control'
  .form-check
    = f.label :admin, class: 'form-check-label' do
      = f.check_box :admin, class: 'form-check-input'
      | 管理者権限
  .form-group
    = f.label :password, 'パスワード'
    = f.password_field :password, class: 'form-control'
  .form-group
    = f.label :password_confirmation, 'パスワード(確認用)'
    = f.password_field :password_confirmation, class: 'form-control'
  = f.submit '登録する', class: 'btn btn-primary'
# app/controllers/admin/users_controller.rb
h1 ユーザー登録

.nav.justify-content-end
  = link_to '一覧', admin_users_path, class: 'nav-link'

= render partial: 'form', locals: { user: @user }
  • editアクション, ビューを作成します。
# app/controllers/admin/users_controller.rb
class Admin::UsersController < ApplicationController
  #...

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

  #...
end
# app/views/admin/users/edit.html.slim
h1 ユーザー編集

.nav.justify-content-end
  = link_to '一覧', admin_users_path, class: 'nav-link'

= render partial: 'form', locals: { user: @user }
  • 動作確認をします。

f:id:t_y_code:20201026161344p:plain

  • updateアクションを作成します。
# app/controllers/admin/users_controller.rb
class Admin::UsersController < ApplicationController
  #...

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

    if @user.update(user_params)
      redirect_to admin_user_url(@user), notice: "ユーザー「#{@user.name}」を更新しました。"
    else
      render :edit
    end
  end

  #...
end
  • 動作確認をします。

f:id:t_y_code:20201026161648p:plain

  • destroyアクションを作成します。
# app/controllers/admin/users_controller.rb
class Admin::UsersController < ApplicationController
  #...

  def destroy
    @user = User.find(params[:id])
    @user.destroy
    redirect_to admin_users_url, notice: "ユーザー「#{@user.name}」を削除しました。"
  end

  #...
end
  • 動作確認します。

f:id:t_y_code:20201026161847p:plain

  • 最後に翻訳情報を追記します。
  • 以上でCRUD機能が完成しました。
4-5-6 ログインのフォームを表示する
  • 次にログイン機能を追加していきます。まずはSessionsControllerを作成します。
$ rails g controller Sessions new
  • ルーティングにて名前付きルートを設定します。
# config/routes.rb
Rails.application.routes.draw do
  get '/login', to: 'sessions/new'

  #...
end
  • newビューにログイン画面を実装します。
# app/views/sessions/new.html.slim
h1 ログイン

= form_with scope: :session, local: true do |f|
  .form-group
    = f.label :email, 'メールアドレス'
    = f.text_field :email, class: 'form-control', id: 'session_email'
  .form-group
    = f.label :password, 'パスワード'
    = f.password_field :password, class: 'form-control', id: 'session_password'
  = f.submit 'ログインする', class: 'btn btn-primary'
  • ビューの動作確認をします。

f:id:t_y_code:20201026162907p:plain

4-5-7 ログインの実行
  • ログインの実装をします。まずはルーティングにて名前付きルートを設定します。
# config/routes.rb
Rails.application.routes.draw do
  get '/login', to: 'sessions/new'
  post '/login', to: 'sessions#create'

  #...
end
  • createアクションを実装します。
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  def new
  end

  def create
    user = User.find_by(email: session_params[:email])

    if user&.authenticate(session_params[:password])
      session[:user_id] = user.id
      redirect_to root_url, notice: 'ログインしました。'
    else
      render :new
    end
  end

  private
    def session_params
      params.require(:session).permit(:email, :password)
    end
end
  • ログイン機能の動作確認をします。

f:id:t_y_code:20201026163735p:plain

4-5-8 ログイン情報の取得を簡単にする
  • ログインしたユーザーの情報をどのコントローラ, ビューからでも取得出来るようにしておく。helper_methodで指定したメソッドはビューから使用出来る。
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  helper_method :current_user

  private
    def current_user
      @current_user ||= User.find_by(id: session[:user_id]) if session[:user_id]
    end
end
4-5-9 ログアウト機能を実装する
  • まずはログアウトのルーティングを作成します。
# config/routes.rb
Rails.application.routes.draw do
  #...

  delete '/logout', to: 'sessions#destroy'

  #...
end
  • 次にdestroyアクションを作成します。
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  #...

  def destroy
    reset_session
    redirect_to root_url, notice: 'ログアウトしました。'
  end

  #...
end
  • 最後にアプリケーション内にログアウトのリンクを作成します。
# app/views/layouts/application.html.slim
doctype html
html
  head
    #...
  body
    .app-title.navbar.navbar-expand-md.navbar-light.bg-light
      .navbar-brand Taskleaf

      ul.navbar-nav.ml-auto
        - if current_user
          li.nav-item= link_to 'タスク一覧', tasks_path, class: 'nav-link'
          li.nav-item= link_to 'ユーザー一覧', admin_users_path, class: 'nav-link'
          li.nav-item= link_to 'ログアウト', logout_path, method: :delete, class: 'nav-link'
        - else
          li.nav-item= link_to 'ログイン', login_path, class: 'nav-link'
    .container
      - if flash.notice.present?
        .alert.alert-success= flash.notice
      = yield
  • ログアウトの動作確認をします。

f:id:t_y_code:20201026164952p:plain

4-5-10 ログインしていなければタスク管理を利用できなくする
  • before_actionを利用してタスク管理機能はログイン状態でなければ使用出来ないようにします。
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  helper_method :current_user
  before_action :login_required

  private
    #...

    def login_required
      redirect_to login_url unless current_user
    end
end
  • しかしこのままだとログイン画面もlogin_requiredメソッドが実行されてしまうためSessionsControllerではlogin_requiredメソッドはスキップするように設定する。
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  skip_before_action :login_required

  #...
end
4-5-11-1 データベース上でUserとTaskを紐付ける
  • 現在は全てのタスクが表示されているがこれをログインしたユーザーと関連付けされたタスクのみを表示するよう変更する。
  • まずはTaskとUserの関連付けを行うためにマイグレーションを作成する。
$ rails g migration AddUserIdToTasks
  • マイグレーションファイルを編集する。バージョンを上げる際、現在あるタスクを全て削除するSQLを実行する。これを行わないとNOT_NULL制約に引っかかる可能性がある。
# db/migrate/***_add_user_id_to_tasks.rb
class AddUserIdToTasks < ActiveRecord::Migration[6.0]
  def up
    execute 'DELETE FROM taks;'
    add_reference :tasks, :user, null: false, index: true
  end

  def down
    remove_reference :tasks, :user, index: true
  end
end
  • マイグレートする。(redoでバージョンダウンも確認する)
$ rails db:migrate:redo
4-5-11-2 Railsの「関連」を定義する
  • 各モデルに関連付けを定義します。
# app/models/user.rb
class User < ApplicationRecord
  #...

  has_many :tasks
end
# app/models/task.rb
class Task < ApplicationRecord
  #...

  belong_to :user

  #...
end
4-5-11-3 ログインしているユーザーのTaskデータの登録
  • Tasksコントローラのcreateアクションを変更しユーザと関連付けされたTaskを登録するようにします。
# app/controllers/tasks_controller.rb
class TasksController < ApplicationController
  #...

  def create
    @task = current_user.tasks.new(task_params)
    if @task.save
      redirect_to tasks_url, notice: "タスク「#{@task.name}」を登録しました。"
    else
      render :new
    end
  end

  #...
end
4-5-11-4 ログインしているユーザーのTaskデータだけを読み出す
  • Tasksコントローラのindexアクションを変更してログインしているユーザーのTaskのみを取得するようにします。
# app/controllers/tasks_controller.rb
class TasksController < ApplicationController
  def index
    @tasks = current_user.tasks
  end

  #...
end
  • show, edit, update, destroyアクション内のTask.find(params[:id])をcurrent_user.tasks.find(params[:id])へ変更します。こうすることで他人のtaskを閲覧できなくなります。
4-5-12 管理機能を管理者ユーザだけに利用させるようにする
  • 現在はどのユーザでも管理機能を使用出来てしまう。まずナビゲーションバーのユーザー一覧リンクを管理者のみ表示するよう変更します。
# app/views/layouts/application.html.slim
doctype html
html
  head
    #...
  body
    .app-title.navbar.navbar-expand-md.navbar-light.bg-light
      .navbar-brand Taskleaf

      ul.navbar-nav.ml-auto
        - if current_user
          li.nav-item= link_to 'タスク一覧', tasks_path, class: 'nav-link'
          - if current_user.admin?
            li.nav-item= link_to 'ユーザー一覧', admin_users_path, class: 'nav-link'
          li.nav-item= link_to 'ログアウト', logout_path, method: :delete, class: 'nav-link'
        - else
          li.nav-item= link_to 'ログイン', login_path, class: 'nav-link'
    .container
      #...
  • Admin::Userコントローラをadminユーザのみ表示出来るようにします。
# app/controllers/admin/users_controller.rb
class Admin::UsersController < ApplicationController
  before_action :require_admin

  #...

  private
    #...

    def require_admin
      redirect_to root_url unless current_user.admin?
    end
end
4-7 タスク一覧を作成日時の新しい順に表示する
  • 絞り込み条件を追加してタスク一覧を作成日時の新しい順に表示する。
# app/controllers/tasks_controller.rb
class TasksController < ApplicationController
  def index
    @tasks = current_user.tasks.order(created_at: :desc)
  end

  #...
end
4-8 scopeを活用する
  • 例えばTaskモデルを作成日時の新しい順に表示することが多くあるとしたら一々Task.order(created_at: :desc)と書くのは煩わしい。scopeメソッドを使用することで同じ呼び出し文章を書かなくて済むようになる。
# app/models/task.rb
class Task < ApplicationRecord
  #...

  scope :recent, -> { order(created_at: :desc) }

  #...
end
  • 上記のように定義すればTask.recentがTask.order(created_at: :desc)と同じ機能を果たすようになる。
4-9 フィルタを使い重複を避ける
  • Tasksコントローラにはshow, edit, update, destroyアクションにそれぞれ@task = current_user.tasks.find(params[:id])が定義されている。DRYの原則に反しているため定義を1つにまとめます。
# app/controllers/tasks_controller.rb
class TasksController < ApplicationController
  before_action :set_task, only: [:show, :edit, :update, :destroy]

  def show
  end

  def edit
  end

  def update
    task.update!(task_params)
    redirect_to tasks_url, notice: "タスク「#{task.name}」を更新しました。"
  end

  def destroy
    task.destroy
    redirect_to tasks_url, notice: "タスク「#{task.name}」を削除しました。"
  end

  #...

  private
    #...

    def set_task
      @task = current_user.tasks.find(params[:id])
    end
end
4-10 詳しい説明に含まれるURLをリンクとして表示する
  • タスクの詳しい説明にURLが含まれる場合リンク化させます。rails_autolink gemを使用するのでgemを追加します。
  • gemを追加したのでTasksコントローラのshowビューを編集してオートリンク化させます。
# app/views/tasks/show.html.slim
#...
    tr
      th= Task.human_attribute_name(:description)
      td= auto_link(simple_format(h(@task.description), {}, sanitize: false, wrapper_tag: "div"))
#...

f:id:t_y_code:20201026175327p:plain

  • 以上で4章は終了です。