今日やったこと18
第14章 ユーザーをフォローする
14.1 Relationshipモデル
ユーザーをフォローする機能を実装するために、Relationshipデータモデルを作成する。
relationships | |
id | integer |
follower_id | integer |
followed_id | integer |
created_at | datetime |
updated_at | datetime |
$ rails generate model Relationship follower_id:integer followed_id:integer
relationshipsテーブルにインデックスを追加し、データベースのマイグレーションを行う。
db/migrate/[timestamp]_create_relationships.rb class CreateRelationships < ActiveRecord::Migration[5.0] def change create_table :relationships do |t| t.integer :follower_id t.integer :followed_id t.timestamps end add_index :relationships, :follower_id add_index :relationships, :followed_id add_index :relationships, [:follower_id, :followed_id], unique: true end end
$ rails db:migrate
User/Relationshipの関連付け
app/models/user.rb class User < ApplicationRecord has_many :microposts, dependent: :destroy has_many :active_relationships, class_name: "Relationship", foreign_key: "follower_id", dependent: :destroy . . . end
app/models/relationship.rb class Relationship < ApplicationRecord belongs_to :follower, class_name: "User" belongs_to :followed, class_name: "User" end
Relationshipのバリデーション
今の時点では生成されたRelationship用のfixtureファイルは空にしておく。
test/models/relationship_test.rb require 'test_helper' class RelationshipTest < ActiveSupport::TestCase def setup @relationship = Relationship.new(follower_id: users(:michael).id, followed_id: users(:archer).id) end test "should be valid" do assert @relationship.valid? end test "should require a follower_id" do @relationship.follower_id = nil assert_not @relationship.valid? end test "should require a followed_id" do @relationship.followed_id = nil assert_not @relationship.valid? end end
app/models/relationship.rb class Relationship < ApplicationRecord belongs_to :follower, class_name: "User" belongs_to :followed, class_name: "User" validates :follower_id, presence: true validates :followed_id, presence: true end
フォローしているユーザー
Userモデルにfollowingの関連付けを追加する。
app/models/user.rb class User < ApplicationRecord has_many :microposts, dependent: :destroy has_many :active_relationships, class_name: "Relationship", foreign_key: "follower_id", dependent: :destroy has_many :following, through: :active_relationships, source: :followed # 追加 . . . end
followingで取得した集合をより簡単に取り扱うために、followやunfollowといったメソッドを追加する。
追加するメソッドはまずテストから先に書いていく。
test/models/user_test.rb require 'test_helper' class UserTest < ActiveSupport::TestCase . . . # following” 関連のメソッドをテスト test "should follow and unfollow a user" do michael = users(:michael) archer = users(:archer) assert_not michael.following?(archer) michael.follow(archer) assert michael.following?(archer) michael.unfollow(archer) assert_not michael.following?(archer) end end
この時点でテストは失敗するので、次にfollowing関連のメソッドを追加する。
app/models/user.rb class User < ApplicationRecord . . . def feed . . . end # ユーザーをフォローする def follow(other_user) active_relationships.create(followed_id: other_user.id) end # ユーザーをフォロー解除する def unfollow(other_user) active_relationships.find_by(followed_id: other_user.id).destroy end # 現在のユーザーがフォローしてたらtrueを返す def following?(other_user) following.include?(other_user) end private . . . end
再びテストをして成功。
今日やったこと17
画像の検証
画像サイズやフォーマットに対するバリデーションを実装し、サーバー用とクライアント (ブラウザ) 用の両方に追加する。
生成されたアップローダーの中のコメントアウトされたコードを取り消すことで、画像のファイル名から有効な拡張子 (PNG/GIF/JPEGなど) を検証することができる。
app/uploaders/picture_uploader.rb class PictureUploader < CarrierWave::Uploader::Base storage :file # アップロードファイルの保存先ディレクトリは上書き可能 # 下記はデフォルトの保存先 def store_dir "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}" end # アップロード可能な拡張子のリスト def extension_white_list %w(jpg jpeg gif png) end end
ファイルサイズに対するバリデーションはRailsの既存のオプション (presenceやlengthなど) にはないので、手動でpicture_sizeという独自のバリデーションを定義する。独自のバリデーションを定義するためには今まで使っていたvalidatesメソッドではなく、validateメソッドを使う。
app/models/micropost.rb class Micropost < ApplicationRecord belongs_to :user default_scope -> { order(created_at: :desc) } mount_uploader :picture, PictureUploader validates :user_id, presence: true validates :content, presence: true, length: { maximum: 140 } validate :picture_size #validateメソッドを使う private # アップロードされた画像のサイズをバリデーションする def picture_size if picture.size > 5.megabytes errors.add(:picture, "should be less than 5MB") end end end
上記で定義した画像のバリデーションをビューに組み込むために、クライアント側に2つの処理を追加する。
app/views/shared/_micropost_form.html.erb <%= form_for(@micropost) do |f| %> <%= render 'shared/error_messages', object: f.object %> <div class="field"> <%= f.text_area :content, placeholder: "Compose new micropost..." %> </div> <%= f.submit "Post", class: "btn btn-primary" %> <span class="picture"> <!-- acceptパラメータを付与する --> <%= f.file_field :picture, accept: 'image/jpeg,image/gif,image/png' %> </span> <% end %> <!-- jQueryを追加 --> <script type="text/javascript"> $('#micropost_picture').bind('change', function() { var size_in_megabytes = this.files[0].size/1024/1024; if (size_in_megabytes > 5) { alert('Maximum file size is 5MB. Please choose a smaller file.'); } }); </script>
画像のリサイズ
画像をリサイズするためには、画像を操作するプログラムが必要になる。
ImageMagickというプログラムを開発環境にインストールする。
$ sudo yum install -y ImageMagick
次に、MiniMagickというImageMagickとRubyを繋ぐgemを使って、画像をリサイズする。
app/uploaders/picture_uploader.rb class PictureUploader < CarrierWave::Uploader::Base include CarrierWave::MiniMagick # 縦横どちらかが400pxを超えていた場合、適切なサイズに縮小する process resize_to_limit: [400, 400] storage :file # アップロードファイルの保存先ディレクトリは上書き可能 # 下記はデフォルトの保存先 def store_dir "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}" end # アップロード可能な拡張子のリスト def extension_white_list %w(jpg jpeg gif png) end end
本番環境での画像アップロード
開発環境ではstorage :fileという行によって、ローカルのファイルシステムに画像を保存するようになっているが、本番環境ではファイルシステムではなくクラウドストレージサービスに画像を保存するようにする。
クラウドストレージに保存するためには、fog gemを使うと簡単できる。
app/uploaders/picture_uploader.rb class PictureUploader < CarrierWave::Uploader::Base include CarrierWave::MiniMagick process resize_to_limit: [400, 400] # 環境ごとに保存先を切り替える if Rails.env.production? storage :fog else storage :file end
今日やったこと16
13.4 マイクロポストの画像投稿
投稿した画像を扱ったり、その画像をMicropostモデルと関連付けするために、CarrierWaveという画像アップローダーを使う。まずはcarrierwave gemをGemfileに追加する。画像をリサイズしたり、本番環境で画像をアップロードするために、mini_magick gemとfog gemsも追加する。
source 'https://rubygems.org' gem 'rails', '5.1.4' gem 'bcrypt', '3.1.11' gem 'faker', '1.7.3' gem 'carrierwave', '1.2.2' #追加 gem 'mini_magick', '4.7.0' #追加 gem 'will_paginate', '3.1.5' gem 'bootstrap-will_paginate', '1.0.0' . . . group :production do gem 'pg', '0.20.0' gem 'fog', '1.42' #追加 end . . .
$ bundle install
CarrierWaveを導入すると、Railsのジェネレーターで画像アップローダーが生成できるようになる。
$ rails generate uploader Picture
関連付けされる属性には画像のファイル名が格納されるため、String型にしてpicture属性をMicropostモデルに追加する。
$ rails generate migration add_picture_to_microposts picture:string $ rails db:migrate
CarrierWaveに画像と関連付けたモデルを伝えるためには、mount_uploaderというメソッドを使い、引数に属性名のシンボルと生成されたアップローダーのクラス名を取る。これをMicropostモデルに追加する。
app/models/micropost.rb class Micropost < ApplicationRecord belongs_to :user default_scope -> { order(created_at: :desc) } mount_uploader :picture, PictureUploader #追加 validates :user_id, presence: true validates :content, presence: true, length: { maximum: 140 } end
Homeページにアップローダーを追加するために、マイクロポストのフォームにfile_fieldタグを追加。
app/views/shared/_micropost_form.html.erb <%= form_for(@micropost) do |f| %> <%= render 'shared/error_messages', object: f.object %> <div class="field"> <%= f.text_area :content, placeholder: "Compose new micropost..." %> </div> <%= f.submit "Post", class: "btn btn-primary" %> <span class="picture"> <%= f.file_field :picture %> <!-- file_fieldタグを追加 --> </span> <% end %>
micropost_paramsメソッドにpicture属性を追加する。
app/controllers/microposts_controller.rb class MicropostsController < ApplicationController before_action :logged_in_user, only: [:create, :destroy] before_action :correct_user, only: :destroy . . . private def micropost_params params.require(:micropost).permit(:content, :picture) # picture属性を追加 end def correct_user @micropost = current_user.microposts.find_by(id: params[:id]) redirect_to root_url if @micropost.nil? end end
Micropostパーシャルのimage_tagヘルパーでアップロードされた画像を描画する。
app/views/microposts/_micropost.html.erb <li id="micropost-<%= micropost.id %>"> <%= link_to gravatar_for(micropost.user, size: 50), micropost.user %> <span class="user"><%= link_to micropost.user.name, micropost.user %></span> <span class="content"> <%= micropost.content %> <!-- image_tagヘルパーで画像を描画 --> <%= image_tag micropost.picture.url if micropost.picture? %> </span> <span class="timestamp"> Posted <%= time_ago_in_words(micropost.created_at) %> ago. <% if current_user?(micropost.user) %> <%= link_to "delete", micropost, method: :delete, data: { confirm: "You sure?" } %> <% end %> </span> </li>
今日やったこと15
13.3 マイクロポストを操作する
マイクロポストリソースのルーティングを設定する。
resources :microposts, only: [:create, :destroy]
HTTPリクエスト | URL | アクション | 名前付きルート |
POST | /microposts | create | microposts_path |
DELETE | /microposts/1 | destroy | micropost_path(micropost) |
関連付けられたユーザーを通してマイクロポストにアクセスするので、createアクションやdestroyアクションを利用するユーザーは、ログイン済みでなければならない。正しいリクエストを各アクションに向けて発行し、マイクロポストの数が変化していないかどうか、また、リダイレクトされるかどうかをテストする。
test/controllers/microposts_controller_test.rb require 'test_helper' class MicropostsControllerTest < ActionDispatch::IntegrationTest def setup @micropost = microposts(:orange) end test "should redirect create when not logged in" do assert_no_difference 'Micropost.count' do post microposts_path, params: { micropost: { content: "Lorem ipsum" } } end assert_redirected_to login_url end test "should redirect destroy when not logged in" do assert_no_difference 'Micropost.count' do delete micropost_path(@micropost) end assert_redirected_to login_url end end
テストは失敗。
Micropostsコントローラでもlogged_in_userメソッドを使えるようにするために、各コントローラが継承するApplicationコントローラに 、このメソッドを移す。
コードが重複しないよう、このときUsersコントローラからもlogged_in_userを削除しておく。
app/controllers/application_controller.rb class ApplicationController < ActionController::Base protect_from_forgery with: :exception include SessionsHelper private # ユーザーのログインを確認する def logged_in_user unless logged_in? store_location flash[:danger] = "Please log in." redirect_to login_url end end end
Micropostsコントローラの各アクションでlogged_in_userメソッドを使えるようにする。
app/controllers/microposts_controller.rb class MicropostsController < ApplicationController before_action :logged_in_user, only: [:create, :destroy] def create end def destroy end end
再びテストし成功。
マイクロポストを作成する.
マイクロポストのcreateアクションを作っていく。
app/controllers/microposts_controller.rb class MicropostsController < ApplicationController before_action :logged_in_user, only: [:create, :destroy] def create @micropost = current_user.microposts.build(micropost_params) if @micropost.save flash[:success] = "Micropost created!" redirect_to root_url else render 'static_pages/home' end end def destroy end private def micropost_params params.require(:micropost).permit(:content) end end
マイクロポスト作成フォームを構築するために、if-else文の分岐を使ってサイト訪問者がログインしているかどうかに応じてコードを書き分ける。
app/views/static_pages/home.html.erb <% if logged_in? %> <div class="row"> <aside class="col-md-4"> <section class="user_info"> <%= render 'shared/user_info' %> </section> <section class="micropost_form"> <%= render 'shared/micropost_form' %> </section> </aside> </div> <% else %> <div class="center jumbotron"> <h1>Welcome to the Sample App</h1> <h2> This is the home page for the <a href="https://railstutorial.jp/">Ruby on Rails Tutorial</a> sample application. </h2> <%= link_to "Sign up now!", signup_path, class: "btn btn-lg btn-primary" %> </div> <%= link_to image_tag("rails.png", alt: "Rails logo"), 'http://rubyonrails.org/' %> <% end %>
パーシャルを作る。
サイドバーで表示するユーザー情報のパーシャル
app/views/shared/_user_info.html.erb <%= link_to gravatar_for(current_user, size: 50), current_user %> <h1><%= current_user.name %></h1> <span><%= link_to "view my profile", current_user %></span> <span><%= pluralize(current_user.microposts.count, "micropost") %></span>
マイクロポスト投稿フォームのパーシャル
app/views/shared/_micropost_form.html.erb <%= form_for(@micropost) do |f| %> <%= render 'shared/error_messages', object: f.object %> <div class="field"> <%= f.text_area :content, placeholder: "Compose new micropost..." %> </div> <%= f.submit "Post", class: "btn btn-primary" %> <% end %>
フォームが動くようにするために@micropostを定義する.。
app/controllers/static_pages_controller.rb class StaticPagesController < ApplicationController def home @micropost = current_user.microposts.build if logged_in? end def help end def about end def contact end end
エラーメッセージのパーシャルを再定義する。
Userオブジェクト以外でも動作するようにerror_messagesパーシャルを更新する。
app/views/shared/_error_messages.html.erb <% if object.errors.any? %> <div id="error_explanation"> <div class="alert alert-danger"> The form contains <%= pluralize(object.errors.count, "error") %>. </div> <ul> <% object.errors.full_messages.each do |msg| %> <li><%= msg %></li> <% end %> </ul> </div> <% end %>
このパーシャルは他の場所でも使われているので、それぞれのビューでも使えるように更新する。
これでマイクロポスト作成フォームが表示できる。
Homeページに投稿したマイクロポストを表示する部分を実装する。
Userモデルにfeedメソッドを作る。
app/models/user.rb class User < ApplicationRecord . . . def feed Micropost.where("user_id = ?", id) end private . . . end
サンプルアプリケーションでフィードを使うために、現在のユーザーのページ分割されたフィードに@feed_itemsインスタンス変数を追加して、フィード用のパーシャルをHomeページに追加する。
app/controllers/static_pages_controller.rb class StaticPagesController < ApplicationController def home if logged_in? @micropost = current_user.microposts.build @feed_items = current_user.feed.paginate(page: params[:page]) end end . . . end
app/views/shared/_feed.html.erb <% if @feed_items.any? %> <ol class="microposts"> <%= render @feed_items %> </ol> <%= will_paginate @feed_items %> <% end %>
パーシャルで、renderに@feed_itemsを渡しているのは、Railsが対応する名前のパーシャルを、渡されたリソースのディレクトリ内から探しにいくことができるため。
Homeページにステータスフィードを追加する。
app/views/static_pages/home.html.erb <% if logged_in? %> <%= render 'shared/home_login' %> <% else %> <%= render 'shared/home_logout' %> <% end %>
app/views/shared/_home_login.html.erb <div class="row"> <aside class="col-md-4"> <section class="user_info"> <%= render 'shared/user_info' %> </section> <section class="micropost_form"> <%= render 'shared/micropost_form' %> </section> </aside> <div class="col-md-8"> <h3>Micropost Feed</h3> <%= render 'shared/feed' %> </div> </div>
マイクロポストを削除する
自分が投稿したマイクロポストに対してのみ削除リンクが動作するように機能を追加する。
最初にマイクロポストのパーシャルに削除リンクを追加。
app/views/microposts/_micropost.html.erb <li id="micropost-<%= micropost.id %>"> <%= link_to gravatar_for(micropost.user, size: 50), micropost.user %> <span class="user"><%= link_to micropost.user.name, micropost.user %></span> <span class="content"><%= micropost.content %></span> <span class="timestamp"> Posted <%= time_ago_in_words(micropost.created_at) %> ago. <% if current_user?(micropost.user) %> <%= link_to "delete", micropost, method: :delete, data: { confirm: "You sure?" } %> <% end %> </span> </li>
次に、Micropostsコントローラのdestroyアクションを定義する。
app/controllers/microposts_controller.rb class MicropostsController < ApplicationController before_action :logged_in_user, only: [:create, :destroy] before_action :correct_user, only: :destroy . . . def destroy @micropost.destroy flash[:success] = "Micropost deleted" redirect_to request.referrer || root_url end private def micropost_params params.require(:micropost).permit(:content) end def correct_user @micropost = current_user.microposts.find_by(id: params[:id]) redirect_to root_url if @micropost.nil? end end
フィード画面のマイクロポストをテストする
まずはマイクロポスト用のfixtureに、別々のユーザーに紐付けられたマイクロポストを追加していく。
test/fixtures/microposts.yml . . . ants: content: "Oh, is that what you want? Because that's how you get ants!" created_at: <%= 2.years.ago %> user: archer zone: content: "Danger zone!" created_at: <%= 3.days.ago %> user: archer tone: content: "I'm sorry. Your words made sense, but your sarcastic tone did not." created_at: <%= 10.minutes.ago %> user: lana van: content: "Dude, this van's, like, rolling probable cause." created_at: <%= 4.hours.ago %> user: lana
次に、自分以外のユーザーのマイクロポストは削除をしようとすると、適切にリダイレクトされることをテストする。
test/controllers/microposts_controller_test.rb require 'test_helper' class MicropostsControllerTest < ActionDispatch::IntegrationTest def setup @micropost = microposts(:orange) end . . . test "should redirect destroy for wrong micropost" do log_in_as(users(:michael)) micropost = microposts(:ants) assert_no_difference 'Micropost.count' do delete micropost_path(micropost) end assert_redirected_to root_url end end
最後に統合テストを書くためにファイルを生成。
$ rails generate integration_test microposts_interface
統合テストでは、ログイン、マイクロポストのページ分割の確認、無効なマイクロポストを投稿、有効なマイクロポストを投稿、マイクロポストの削除、そして他のユーザーのマイクロポストには [delete] リンクが表示されないことを確認、といった順でテストしていく。
test/integration/microposts_interface_test.rb require 'test_helper' class MicropostsInterfaceTest < ActionDispatch::IntegrationTest def setup @user = users(:michael) end # マイクロポストのUIに対する統合テスト test "micropost interface" do log_in_as(@user) get root_path assert_select 'div.pagination' # 無効な送信 assert_no_difference 'Micropost.count' do post microposts_path, params: { micropost: { content: "" } } end assert_select 'div#error_explanation' # 有効な送信 content = "This micropost really ties the room together" assert_difference 'Micropost.count', 1 do post microposts_path, params: { micropost: { content: content } } end assert_redirected_to root_url follow_redirect! assert_match content, response.body # 投稿を削除する assert_select 'a', text: 'delete' first_micropost = @user.microposts.paginate(page: 1).first assert_difference 'Micropost.count', -1 do delete micropost_path(first_micropost) end # 違うユーザーのプロフィールにアクセス (削除リンクがないことを確認) get user_path(users(:archer)) assert_select 'a', text: 'delete', count: 0 end end
今日やったこと14
第13章 ユーザーのマイクロポスト
13.1 Micropostモデル
Micropostデータモデルの構造
microposts
カラム名 | 型 |
id | integer |
content | text |
user_id | integer |
create_at | datetime |
update_at | datetime |
マイクロポストの投稿にString型ではなくText型を使っている理由は、Text型の方が投稿フォームを表現豊かにしたり、変更に柔軟に対応できるため。
Micropostモデルを生成。
$ rails generate model Micropost content:text user:references
references型を利用することで、自動的にインデックスと外部キー参照付きのuser_idカラムが追加され、UserとMicropostを関連付けする下準備をしてくれる。
生成されたマイグレーションファイルでuser_idとcreated_atカラムにインデックスを付与する。こうすることで、user_idに関連付けられたすべてのマイクロポストを作成時刻の逆順で取り出しやすくなる。
db/migrate/[timestamp]_create_microposts.rb class CreateMicroposts < ActiveRecord::Migration[5.0] def change create_table :microposts do |t| t.text :content t.references :user, foreign_key: true t.timestamps end add_index :microposts, [:user_id, :created_at] # インデックスを付与する end end
マイグレーションを使って、データベースを更新。
$ rails db:migrate
Micropostモデルのバリデーションに対するテストを追加。
test/models/micropost_test.rb require 'test_helper' class MicropostTest < ActiveSupport::TestCase def setup @user = users(:michael) @micropost = Micropost.new(content: "Lorem ipsum", user_id: @user.id) end test "should be valid" do assert @micropost.valid? end test "user id should be present" do @micropost.user_id = nil assert_not @micropost.valid? end test "content should be present" do @micropost.content = " " assert_not @micropost.valid? end test "content should be at most 140 characters" do @micropost.content = "a" * 141 assert_not @micropost.valid? end end
この時点ではテストは失敗するのでMicropostモデルのバリデーションを追加する。
app/models/micropost.rb class Micropost < ApplicationRecord belongs_to :user validates :user_id, presence: true validates :content, presence: true, length: { maximum: 140 } end
これでテストに成功しました。
UserモデルとMicropostモデルを関連付ける。
関連付けにはbelongs_to/has_manyを使う。
Micropostモデルの方では、belongs_to :userというコードが必要になるが、マイグレーションによって自動生成されている。Userモデルはhas_many :micropostsと追加する必要があるので追加していく。
app/models/user.rb class User < ApplicationRecord has_many :microposts . . . end
ユーザーの最も新しいマイクロポストを最初に表示するようにするために、default scopeというテクニックを使う。
まずはマイクロポストの順序付けのテストを先に作っていく。
test/models/micropost_test.rb require 'test_helper' class MicropostTest < ActiveSupport::TestCase . . . test "order should be most recent first" do assert_equal microposts(:most_recent), Micropost.first end end
テストは失敗。
テストではマイクロポスト用のfixtureファイルからサンプルデータを読み出しているので、fixtureファイルを用意する。
test/fixtures/microposts.yml orange: content: "I just ate an orange!" created_at: <%= 10.minutes.ago %> tau_manifesto: content: "Check out the @tauday site by @mhartl: http://tauday.com" created_at: <%= 3.years.ago %> cat_video: content: "Sad cats are sad: http://youtu.be/PKffm2uI4dk" created_at: <%= 2.hours.ago %> most_recent: content: "Writing a short test" created_at: <%= Time.zone.now %>
default_scopeを使いマイクロポストを投稿の古い順番に並べる。
app/models/micropost.rb class Micropost < ApplicationRecord belongs_to :user default_scope -> { order(created_at: :desc) } validates :user_id, presence: true validates :content, presence: true, length: { maximum: 140 } end
再びテストし成功。
dependent: :destroyというオプションを使うと、ユーザーが削除されたときに、そのユーザーに関連づいたマイクロポストも一緒に削除されるようになる。
app/models/user.rb class User < ApplicationRecord has_many :microposts, dependent: :destroy . . . end
正しく動くかどうか、テストする。
test/models/user_test.rb require 'test_helper' class UserTest < ActiveSupport::TestCase def setup @user = User.new(name: "Example User", email: "user@example.com", password: "foobar", password_confirmation: "foobar") end . . . test "associated microposts should be destroyed" do @user.save @user.microposts.create!(content: "Lorem ipsum") assert_difference 'Micropost.count', -1 do @user.destroy end end end
13.2 マイクロポストを表示する
ユーザーのプロフィール画面 (show.html.erb) でそのユーザーのマイクロポストや投稿した総数を表示させる。
まずは、Micropostのコントローラとビューを作成。
$ rails generate controller Microposts
マイクロポストを表示するパーシャルを用意して、ユーザーのshowページ (プロフィール画面) にマイクロポストを追加する。
app/views/microposts/_micropost.html.erb <li id="micropost-<%= micropost.id %>"> <%= link_to gravatar_for(micropost.user, size: 50), micropost.user %> <span class="user"><%= link_to micropost.user.name, micropost.user %></span> <span class="content"><%= micropost.content %></span> <span class="timestamp"> Posted <%= time_ago_in_words(micropost.created_at) %> ago. </span> </li>
app/views/users/show.html.erb <% provide(:title, @user.name) %> <div class="row"> <aside class="col-md-4"> <section class="user_info"> <h1> <%= gravatar_for @user %> <%= @user.name %> </h1> </section> </aside> <div class="col-md-8"> <% if @user.microposts.any? %> <h3>Microposts (<%= @user.microposts.count %>)</h3> <ol class="microposts"> <%= render @microposts %> </ol> <%= will_paginate @microposts %> <% end %> </div> </div>
@microposts変数をwill_paginateに渡す必要があるので、showアクションに追加する。
app/controllers/users_controller.rb class UsersController < ApplicationController . . . def show @user = User.find(params[:id]) @microposts = @user.microposts.paginate(page: params[:page]) end . . . end
サンプルデータにマイクロポストを追加し、開発環境用のデータベースで再度サンプルデータを生成する。
db/seeds.rb . . . users = User.order(:created_at).take(6) 50.times do content = Faker::Lorem.sentence(5) users.each { |user| user.microposts.create!(content: content) } end
$ rails db:migrate:reset $ rails db:seed
マイクロポスト用のCSSを追加する。
app/assets/stylesheets/custom.scss . . . /* microposts */ .microposts { list-style: none; padding: 0; li { padding: 10px 0; border-top: 1px solid #e8e8e8; } .user { margin-top: 5em; padding-top: 0; } .content { display: block; margin-left: 60px; img { display: block; padding: 5px 0; } } .timestamp { color: $gray-light; display: block; margin-left: 60px; } .gravatar { float: left; margin-right: 10px; margin-top: 5px; } } aside { textarea { height: 100px; margin-bottom: 5px; } } span.picture { margin-top: 10px; input { border: 0; } }
プロフィール画面で表示されるマイクロポストに対して、テストを書いていく。
$ rails generate integration_test users_profile
テストのためにfixtureファイルを更新。
test/fixtures/microposts.yml orange: content: "I just ate an orange!" created_at: <%= 10.minutes.ago %> user: michael tau_manifesto: content: "Check out the @tauday site by @mhartl: http://tauday.com" created_at: <%= 3.years.ago %> user: michael cat_video: content: "Sad cats are sad: http://youtu.be/PKffm2uI4dk" created_at: <%= 2.hours.ago %> user: michael most_recent: content: "Writing a short test" created_at: <%= Time.zone.now %> user: michael <% 30.times do |n| %> micropost_<%= n %>: content: <%= Faker::Lorem.sentence(5) %> created_at: <%= 42.days.ago %> user: michael <% end %>
Userプロフィール画面に対するテスト
test/integration/users_profile_test.rb require 'test_helper' class UsersProfileTest < ActionDispatch::IntegrationTest include ApplicationHelper def setup @user = users(:michael) end test "profile display" do get user_path(@user) assert_template 'users/show' assert_select 'title', full_title(@user.name) assert_select 'h1', text: @user.name assert_select 'h1>img.gravatar' assert_match @user.microposts.count.to_s, response.body assert_select 'div.pagination' @user.microposts.paginate(page: 1).each do |micropost| assert_match micropost.content, response.body end end end
$ rails t
今日やったこと13
12.3 パスワードを再設定する
PasswordResetsコントローラのeditアクションの実装をする。
パスワード再設定の送信メールには、パスワード再設定フォームを表示するためのリンクが含まれているので、まずはそのためのビューを設定する。
app/views/password_resets/edit.html.erb <% provide(:title, 'Reset password') %> <h1>Reset password</h1> <div class="row"> <div class="col-md-6 col-md-offset-3"> <%= form_for(@user, url: password_reset_path(params[:id])) do |f| %> <%= render 'shared/error_messages' %> <%= hidden_field_tag :email, @user.email %> <%= 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 "Update password", class: "btn btn-primary" %> <% end %> </div> </div>
editアクションとupdateアクションのどちらの場合も正当な@userが存在する必要があるので、いくつかのbeforeフィルタを使って@userの検索とバリデーションを行う。
app/controllers/password_resets_controller.rb class PasswordResetsController < ApplicationController before_action :get_user, only: [:edit, :update] before_action :valid_user, only: [:edit, :update] . . . def edit end private def get_user @user = User.find_by(email: params[:email]) end # 正しいユーザーかどうか確認する def valid_user unless (@user && @user.activated? && @user.authenticated?(:reset, params[:id])) redirect_to root_url end end end
パスワードを更新するためには、フォームからの送信に対応するupdateアクションが必要になる。
updateアクションでは、下記の4つのケースを考慮する必要がある。
1. パスワード再設定の有効期限が切れていないか
2. 無効なパスワードであれば失敗させる (失敗した理由も表示する)
3. 新しいパスワードが空文字列になっていないか (ユーザー情報の編集ではOKだった)
4. 新しいパスワードが正しければ、更新する
1のために、editとupdateアクションに期限切れかどうかを確認するメソッドとbeforeフィルターを用意することで対応する。
before_action :check_expiration, only: [:edit, :update]
# 期限切れかどうかを確認する def check_expiration if @user.password_reset_expired? flash[:danger] = "Password reset has expired." redirect_to new_password_reset_url end end
パスワード再設定のupdateアクション
app/controllers/password_resets_controller.rb class PasswordResetsController < ApplicationController before_action :get_user, only: [:edit, :update] before_action :valid_user, only: [:edit, :update] before_action :check_expiration, only: [:edit, :update] # 1 への対応 def new end def create @user = User.find_by(email: params[:password_reset][:email].downcase) if @user @user.create_reset_digest @user.send_password_reset_email flash[:info] = "Email sent with password reset instructions" redirect_to root_url else flash.now[:danger] = "Email address not found" render 'new' end end def edit end def update if params[:user][:password].empty? # 3 への対応 @user.errors.add(:password, :blank) render 'edit' elsif @user.update_attributes(user_params) # 4 への対応 log_in @user flash[:success] = "Password has been reset." redirect_to @user else render 'edit' # 2 への対応 end end private def user_params params.require(:user).permit(:password, :password_confirmation) end # beforeフィルタ def get_user @user = User.find_by(email: params[:email]) end # 有効なユーザーかどうか確認する def valid_user unless (@user && @user.activated? && @user.authenticated?(:reset, params[:id])) redirect_to root_url end end # トークンが期限切れかどうか確認する def check_expiration if @user.password_reset_expired? flash[:danger] = "Password reset has expired." redirect_to new_password_reset_url end end end
password_reset_expired?メソッドをUserモデルに追加する。
app/models/user.rb class User < ApplicationRecord . . . # パスワード再設定の期限が切れている場合はtrueを返す def password_reset_expired? reset_sent_at < 2.hours.ago end private . . . end
パスワード再設定のテストをするためにファイルを生成してテストを追加する。
$ rails generate integration_test password_resets
test/integration/password_resets_test.rb require 'test_helper' class PasswordResetsTest < ActionDispatch::IntegrationTest def setup ActionMailer::Base.deliveries.clear @user = users(:michael) end test "password resets" do get new_password_reset_path assert_template 'password_resets/new' # メールアドレスが無効 post password_resets_path, params: { password_reset: { email: "" } } assert_not flash.empty? assert_template 'password_resets/new' # メールアドレスが有効 post password_resets_path, params: { password_reset: { email: @user.email } } assert_not_equal @user.reset_digest, @user.reload.reset_digest assert_equal 1, ActionMailer::Base.deliveries.size assert_not flash.empty? assert_redirected_to root_url # パスワード再設定フォームのテスト user = assigns(:user) # メールアドレスが無効 get edit_password_reset_path(user.reset_token, email: "") assert_redirected_to root_url # 無効なユーザー user.toggle!(:activated) get edit_password_reset_path(user.reset_token, email: user.email) assert_redirected_to root_url user.toggle!(:activated) # メールアドレスが有効で、トークンが無効 get edit_password_reset_path('wrong token', email: user.email) assert_redirected_to root_url # メールアドレスもトークンも有効 get edit_password_reset_path(user.reset_token, email: user.email) assert_template 'password_resets/edit' assert_select "input[name=email][type=hidden][value=?]", user.email # 無効なパスワードとパスワード確認 patch password_reset_path(user.reset_token), params: { email: user.email, user: { password: "foobaz", password_confirmation: "barquux" } } assert_select 'div#error_explanation' # パスワードが空 patch password_reset_path(user.reset_token), params: { email: user.email, user: { password: "", password_confirmation: "" } } assert_select 'div#error_explanation' # 有効なパスワードとパスワード確認 patch password_reset_path(user.reset_token), params: { email: user.email, user: { password: "foobaz", password_confirmation: "foobaz" } } assert is_logged_in? assert_not flash.empty? assert_redirected_to user end end
演習 12.3.3.1
# パスワード再設定の属性を設定する def create_reset_digest self.reset_token = User.new_token update_columns(reset_digest: User.digest(reset_token), reset_sent_at: Time.zone.now) end
演習 12.3.3.2
assert_match "expired", response.body
演習 12.3.3.4
require 'test_helper' class PasswordResetsTest < ActionDispatch::IntegrationTest def setup ActionMailer::Base.deliveries.clear @user = users(:michael) end test "password resets" do ・ ・ ・ assert is_logged_in? assert_nil user.reload.reset_digest #ダイジェストがnilになっているか assert_not flash.empty? assert_redirected_to user end
12.4 本番環境でのメール送信 (再掲)
production環境とSendGridの設定は11章でやっているため省略。
Gitのトピックブランチをmasterにマージ。
$ rails test $ git add -A $ git commit -m "Add password reset" $ git checkout master $ git merge password-reset
リモートリポジトリにプッシュし、Herokuにデプロイする。
$ rails test $ git push $ git push heroku $ heroku run rails db:migrate
Heroku上でも動作を確認。
今日やったこと12
第12章 パスワードの再設定
全体の流れ
1.ユーザーがパスワードの再設定をリクエストすると、ユーザーが送信したメールアドレスをキーにしてデータベースからユーザーを見つける
2.該当のメールアドレスがデータベースにある場合は、再設定用トークンとそれに対応するリセットダイジェストを生成する
3.再設定用ダイジェストはデータベースに保存しておき、再設定用トークンはメールアドレスと一緒に、ユーザーに送信する有効化用メールのリンクに仕込んでおく
4.ユーザーがメールのリンクをクリックしたら、メールアドレスをキーとしてユーザーを探し、データベース内に保存しておいた再設定用ダイジェストと比較する (トークンを認証する)
5.認証に成功したら、パスワード変更用のフォームをユーザーに表示する
12.1 PasswordResetsリソース
トピックブランチを作成。
$ git checkout -b password-reset
パスワード再設定用のコントローラを作る。その際、newアクションとeditアクションも一緒に生成する。
$ rails generate controller PasswordResets new edit --no-test-framework
ルーティングを設定。
config/routes.rb Rails.application.routes.draw do ・ ・ ・ resources :users resources :account_activations, only: [:edit] resources :password_resets, only: [:new, :create, :edit, :update] end
HTTPリクエスト | URL | Action | 名前付きルート |
---|---|---|---|
GET | /password_reset/new | new | new_password_resets_path |
POST | /password_reset | create | password_resets_path |
GET | /password_reset/(token)/edit | edit | edit_password_resets_url(token) |
PATCH | /password_reset/(token) | update | password_resets_url(token) |
パスワード再設定画面へのリンクを追加する。
app/views/sessions/new.html.erb <% provide(:title, "Log in") %> <h1>Log in</h1> <div class="row"> <div class="col-md-6 col-md-offset-3"> <%= form_for(:session, url: login_path) do |f| %> <%= f.label :email %> <%= f.email_field :email, class: 'form-control' %> <%= f.label :password %> <%= link_to "(forgot password)", new_password_reset_path %> <!-- リンクを追加 --> <%= f.password_field :password, class: 'form-control' %> <%= f.label :remember_me, class: "checkbox inline" do %> <%= f.check_box :remember_me %> <span>Remember me on this computer</span> <% end %> <%= f.submit "Log in", class: "btn btn-primary" %> <% end %> <p>New user? <%= link_to "Sign up now!", signup_path %></p> </div> </div>
reset_digest属性とreset_sent_at属性をUserモデルに追加する。
下記を実行して、マイグレーションに属性を追加。
$ rails generate migration add_reset_to_users reset_digest:string reset_sent_at:datetime
$ rails db:migrate
新しいパスワード再設定画面ビューを作成する。
app/views/password_resets/new.html.erb <% provide(:title, "Forgot password") %> <h1>Forgot password</h1> <div class="row"> <div class="col-md-6 col-md-offset-3"> <%= form_for(:password_reset, url: password_resets_path) do |f| %> <%= f.label :email %> <%= f.email_field :email, class: 'form-control' %> <%= f.submit "Submit", class: "btn btn-primary" %> <% end %> </div> </div>
フォームから送信後、メールアドレスをキーとしてユーザーをデータベースから見つけ、パスワード再設定用トークンと送信時のタイムスタンプでデータベースの属性を更新する必要がある。続いてルートURLにリダイレクトし、フラッシュメッセージをユーザーに表示する。送信が無効の場合は、ログインと同様にnewページを出力してflash.nowメッセージを表示する。
app/controllers/password_resets_controller.rb class PasswordResetsController < ApplicationController def new end def create @user = User.find_by(email: params[:password_reset][:email].downcase) if @user @user.create_reset_digest @user.send_password_reset_email flash[:info] = "Email sent with password reset instructions" redirect_to root_url else flash.now[:danger] = "Email address not found" render 'new' end end def edit end end
Userモデルにパスワード再設定用メソッドを追加。
app/models/user.rb class User < ApplicationRecord attr_accessor :remember_token, :activation_token, :reset_token before_save :downcase_email before_create :create_activation_digest . . . # パスワード再設定の属性を設定する def create_reset_digest self.reset_token = User.new_token update_attribute(:reset_digest, User.digest(reset_token)) update_attribute(:reset_sent_at, Time.zone.now) end # パスワード再設定のメールを送信する def send_password_reset_email UserMailer.password_reset(self).deliver_now end private . . . end
12.2 パスワード再設定のメール送信
Userメイラーのpassword_resetメソッドを変更し、テキストメールのテンプレートとHTMLメールのテンプレートもそれぞれ変更する。
app/mailers/user_mailer.rb class UserMailer < ApplicationMailer ・ ・ ・ def password_reset(user) @user = user mail to: user.email, subject: "Password reset" end end
app/views/user_mailer/password_reset.text.erb
To reset your password click the link below: <%= edit_password_reset_url(@user.reset_token, email: @user.email) %> This link will expire in two hours. If you did not request your password to be reset, please ignore this email and your password will stay as it is.
app/views/user_mailer/password_reset.html.erb
<h1>Password reset</h1> <p>To reset your password click the link below:</p> <%= link_to "Reset password", edit_password_reset_url(@user.reset_token, email: @user.email) %> <p>This link will expire in two hours.</p> <p> If you did not request your password to be reset, please ignore this email and your password will stay as it is. </p>
Railsのメールプレビュー機能でパスワード再設定のメールをプレビューするためにコードを追加する。
test/mailers/previews/user_mailer_preview.rb # Preview all emails at http://localhost:3000/rails/mailers/user_mailer class UserMailerPreview < ActionMailer::Preview ・ ・ ・ # Preview this email at # http://localhost:3000/rails/mailers/user_mailer/password_reset def password_reset user = User.first user.reset_token = User.new_token UserMailer.password_reset(user) end end
パスワード再設定用メイラーメソッドのテストを追加する。
test/mailers/user_mailer_test.rb require 'test_helper' class UserMailerTest < ActionMailer::TestCase ・ ・ ・ test "password_reset" do user = users(:michael) user.reset_token = User.new_token mail = UserMailer.password_reset(user) assert_equal "Password reset", mail.subject assert_equal [user.email], mail.to assert_equal ["noreply@example.com"], mail.from assert_match user.reset_token, mail.body.encoded assert_match CGI.escape(user.email), mail.body.encoded end end