Popular Posts

вторник, 6 марта 2012 г.

Devise + Cancan -- быстрая разработка. Часть вторая.

Привет.
Продолжаем, обещанное вчера -- cancan + devise.
и так создадим ещё одну модель для того, чтобы пользователь мог бы голосовать, ну то есть обычный рейтинг поста.
rails g model vote score:integer
Затем я хочу, чтобы пользователь мог бы только один раз голосовать, поэтому создаю таблицу, где будет показано голосовал
ли пользователь или нет, если что это отношение has_many
#
rails g model users_vote user:references vote:references
#
Остался контроллер для голосования:
rails g controller vote 
Ок, теперь делаем роутинг: открываем config/routes.rb и записываем:
resources :posts do
  resources :vote, :only => [:update] do
    delete 'clear'
  end
end
Что написали? Получается, что ресурс Post содержит в себе ресурс Vote у которого я оставил один стандартный глагол (:update),
а потом говорю, что обращение к методу clear следует через delete метод. Можно было бы записать :only => [:update, :delete], но я хотел именно метод clear, который отражает суть.
Так теперь редактирую модели:
#user.rb
class User < ActiveRecord::Base
 #.........
  has_many :users_votes
  has_many :votes, :through => :users_votes
end
#vote.rb
class Vote < ActiveRecord::Base
  belongs_to :post
  has_many :users_votes
  has_many :users, :through => :users_votes
end
#users_vote.rb
class UsersVote < ActiveRecord::Base
  belongs_to :user
  belongs_to :vote
end
и да обновим Post, ведь нужно же создавать как-то голосование, при создании поста:
class Post < ActiveRecord::Base
  after_create :create_vote #кэлбэк!
  has_one :vote, :dependent => :destroy #не забываем про отношения!

  protected
    def create_vote
      self.vote = Vote.create(:score => 0)
    end
end
Вот всё, как видно связь пользователь и голосов через промежуточную таблицу users_votes Теперь напишем контроллер для голосования:
class VoteController < ApplicationController
  #сразу загружаем ресурсы cancan 
  load_and_authorize_resource
  #а вот и метод update, который увеличивает счётчик голосования 
  def update
    post = Post.find(params[:post_id])
    vote = post.vote
      
    vote.score += 1 if params[:vote][:score] == "Up" 
    vote.score -= 1 if params[:vote][:score] == "Down"
    vote.users << current_user
    vote.save

    flash[:notice] = "You vote counted!"
    redirect_to post_path(post) 
  end
  
  #а этот метод позволяет админу обнулять голосование за пост, 
  #читерство, махинации и подтасовка)
  def clear
    post = Post.find(params[:post_id])
    post.vote.update_attributes(:score => 0)
    
    flash[:notice] = "Clear!" 
    redirect_to post_path(post)
  end
  #также я переопределил исключение, помните в прошлом посте было? 
  #Так вот если оставить стандартное, то 
  #будет перенаправлять на главную, а мне нужно, чтобы оставалось на этой же странице.
  rescue_from CanCan::AccessDenied do |exception|
    flash[:notice] = "You have already voted" 
    redirect_to post_path(Post.find(params[:post_id]))
  end
end
Заметили такую плохую вещь как vote.users << current_user -- я думаю плохой стиль, просто current_user нельзя использовать в моделях, а так бы лучше написать callback. Теперь шаблоны обновим: Простой до невозможности шаблон
#views/vote/_update.html.erb
<% if can? :update, Vote %>
 <%= form_for [@post, @post.vote] do |f| %>
  <%= f.label :score, "+"%>
  <%= f.radio_button :score, "Up" %>
  <%= f.label :score, "-"%>
  <%= f.radio_button :score, "Down" %>
  

  <%= f.submit "vote" %>

 <% end %>
<% end %>

<% if can? :clear, Vote %>
 <%= link_to 'clear', post_vote_clear_path(@post, @post.vote), method: :delete %>
<% end %>
Так этот шаблон рендерится из views/posts/show.html.erb
<%= render 'vote/update' %>
Всё с этим закончили отредактируем Ability (наш cancan)
#models/ability.rb
#Станет такой уже метод
 def initialize(user)
    user ||= User.new

    if user.role? :admin
      can :manage, Post
      can :clear, Vote 
    else
      can :read, :all
      if user.role?(:user)
        can :create, Post
        can :update, Post do |post|
          post.try(:user) == user
        end
      end
    end
    
    unless user.new_record?
    can :update, Vote do |vote|
      !vote.users.include?(user)    
    end
  end
Кто заметил, что теперь админ управляет только Posts? Если бы я оставил :all, то админ мог бы голосовать до бесконечности. Подумайте почему.
Затем добавил привилегию для админа, что он может обнулять голосование.
и добавил проверку на новую запись -- это для гостей, то есть тех, кто не вошёл в систему.
Ну и дальше стандартная штука проверил голосовал ли пользователь .include?(user).
Вот и всё.
Теперь обещанные тесты (только для контроллеров, модели легко проверяются), я не буду давать пояснения к каждому методу, я старался описывать всё,
если что спросите в комментариях. Тесты достаточно понятны, единственное, что может быть не понятно -- это мой английский, которым я владею не очень хорошо ;)
Дальше TL; DR
Сначала фабрики:
#spec/factories.rb
FactoryGirl.define do
  factory :post do
    sequence(:title) {|n| "New Title -- #{n}" }
    content "MyText"
    user
  end
end
#/roles.rb
FactoryGirl.define do
  factory :role do
    name "user"
  end
end
#/users.rb
FactoryGirl.define do
  factory :user do
#здесь генерирую email'ы по возрастанию, потому что пользуюсь 
   #sqlite, для которой Database_cleaner Не действует.
sequence(:email) {|n| "email#{n}@abc.com" } 
    password "1234567"
  end
end
#votes.rb
FactoryGirl.define do
  factory :vote do
    score 0
  end
end
#users_votes.rb - пустая фабрика
FactoryGirl.define do
  factory :users_vote do
  end
end
Сначала для контроллера Post:
require 'spec_helper'

describe PostsController do
  describe "GET index" do
    it "assign all posts" do
      post = Factory(:post)
      get 'index'
      assigns(:posts).should eq([post])
      response.should render_template('posts/index')
    end
  end
  
  describe "GET show" do
    it "should show post" do
      post = Factory(:post)
      get 'show', {:id => post.id}
      assigns(:post).should eq(post)
      response.should render_template('posts/show')
    end 
  end

  describe "GET new" do
    before(:each) {
      @user = Factory(:user)
      @user.roles << Factory(:role) 
      sign_in @user
    }
    it "ssigns a new post as @post if User have ability :user" do
      get 'new'
      assigns(:post).should be_a_new(Post)
      response.should render_template('posts/new')
    end
    it "asigns a new post as @post if User have ability :admin" do
      @user.roles.clear
      @user.roles << Factory(:role, :name => :admin)
      get 'new'
      assigns(:post).should be_a_new(Post)
      response.should render_template('posts/new')
    end
    it "ssigns a new post as @post if User have not ability :user" do
      sign_out @user
      get 'new'
      response.should redirect_to(root_url)
    end
  end

  describe "GET edit" do
    before(:each) {
      @user = Factory(:user)
      @user.roles << Factory(:role)
      @post = Factory(:post, :user => @user)
    }
    it "can edit post if i'm author this post" do
      sign_in @user
      get 'edit', {:id => @post.id }
      assigns(:post).should eq(@post)
      response.should render_template('posts/edit')  
    end
    it "can edit post if i'm the admin" do
      @user.roles.clear
      @user.roles << Factory(:role, :name => :admin)
      sign_in @user
      get 'edit', {:id => @post.id}
      assigns(:post).should eq(@post)
      response.should render_template('posts/edit')   
    end
    it "can not edit post if not author" do
      user = Factory(:user)
      sign_in user
      get 'edit', {:id => @post.id}
      response.should redirect_to(root_url) 
    end
    it "can not edit post if not authorized" do
      get 'edit', {:id => @post.id}
      response.should redirect_to(root_url)   
    end  
  end

  describe "POST create" do
   context "should create post if params is valid" do
      before(:each) {
       @attr = {
        :title   => "new title.",
        :content => "new content."
       }
      }
      it "should create post if user can create post" do
        user = Factory(:user)
        user.roles << Factory(:role)
        sign_in user
        post 'create', {:post => @attr}

        response.should redirect_to(post_path(Post.last))
        assigns(:post).should be_a(Post)
        assigns(:post).should be_persisted 
      end 
      it "redirect_to root_url if user can not create post" do
        post 'create', {:post => @attr}
        response.should redirect_to(root_url)
      end
    end
    context "should not create post if params is not valid" do
      it "should re-render new template if params is invalid" do
        attr = {:title => "new title"}
        
        user = Factory(:user)
        user.roles << Factory(:role)
        sign_in user
        post 'create', {:post => @attr}
        
        response.should render_template('posts/new')
      end 
    end  
  end

  describe "PUT update" do
   before(:each) {
      @user = Factory(:user)
      @user.roles << Factory(:role)
      @post = Factory(:post, :user => @user)
    }
    it "should update post if it is my post" do
      sign_in @user
      put 'update', { :id => @post.id, :post => {:title => 'update title'}}
      response.should redirect_to(post_path(@post))
    end
    it "should update post if i'm the admin" do
      @user.roles.clear
      @user.roles << Factory(:role, :name => :admin)
      sign_in @user
      put 'update', { :id => @post.id, :post => {:title => 'update title'}}
      response.should redirect_to(post_path(@post))  
    end
    it "should not update post if it is not my post" do
      user = Factory(:user)
      user.roles << Factory(:role)
      sign_in user
      put 'update', { :id => @post.id, :post => {:title => 'update title'}}
      response.should redirect_to(root_url) 
    end
    it "should not update post and redirect_to root_url if i can not ability" do
      put 'update', { :id => @post.id, :post => {:title => 'update title'}}
      response.should redirect_to(root_url)
    end   
  end

  describe "DELETE destroy" do
   before(:each) {
     @user = Factory(:user)
      @user.roles << Factory(:role)
      @post = Factory(:post, :user => @user) 
   }
   it "should delete post if i am the admin" do
     @user.roles.clear
     @user.roles << Factory(:role, :name => :admin)
     sign_in @user
     delete 'destroy', { :id => @post.id }
     flash[:notice].should eq("Delete")
     response.should redirect_to(posts_path) 
   end
   it "should not delete post if i am author" do
     sign_in @user
     delete 'destroy', { :id => @post.id }
     response.should redirect_to(root_url) 
   end
   it "should not delete post if i am guest" do
     delete 'destroy', { :id => @post.id }
     response.should redirect_to(root_url) 
   end
  end
end
и вот для Vote
require 'spec_helper'

describe VoteController do
   
   describe "PUT update" do
     before(:each) {
      @user = Factory(:user)
      @user.roles << Factory(:role)
      @post = Factory(:post, :user => @user)
     }
     it "should vote if not voted" do
      sign_in @user
      put 'update', {:post_id => @post.id ,:id => @post.vote.id, :vote => { :score => "Up" } }
        flash[:notice].should eq("You vote counted!")
        response.should redirect_to(post_path(@post))
     end
     it "should not vote if you have already voted" do
      sign_in @user
      put 'update', {:post_id => @post.id ,:id => @post.vote.id, :vote => { :score => "Up" } }
        put 'update', {:post_id => @post.id ,:id => @post.vote.id, :vote => { :score => "Up" } }
        flash[:notice].should eq("You have already voted")
        response.should redirect_to(post_path(@post))
     end
     it "should not vote if you can not abilitiy" do
        put 'update', {:post_id => @post.id ,:id => @post.vote.id, :vote => { :score => "Up" } }
        response.should redirect_to(post_path(@post))
        @post.vote.score.should eq(0)  
     end  
   end

   describe "DELETE clear" do
     before(:each) {
       @user = Factory(:user)
       @user.roles << Factory(:role, :name => :admin)
       @post = Factory(:post, :user => @user) 
     }
     it "should clear if admin" do
       sign_in @user
       delete 'clear', { :id => @post.vote.id, :post_id => @post.id }
      flash[:notice].should eq("Clear!")
       response.should redirect_to(post_path(@post))
     end
     it "should not clear if user" do
       delete 'clear', { :id => @post.vote.id, :post_id => @post.id }
      response.should redirect_to(post_path(@post))
     end
   end
end
Тесты писал интуитивно, поэтому многие куски могут быть лишние. Например меня смущает Создание Ролей.

Вот и всё. Все 24 теста зелёные.

UPD: код на гугле: http://code.google.com/p/example-app/


5 комментариев:

  1. Этот комментарий был удален автором.

    ОтветитьУдалить
  2. Забыл добавить Post_id к таблице Vote

    и я вот сколько не смотрел ( я новичок в рельсах ) - но так и не понял для чего нужно post.try(:user) == user, почему нельзя
    can :update, Post, :user_id => user.id

    ОтветитьУдалить
  3. Хм, структура таблиц мне не очень нравится
    в Votes я бы хранил post_id | ip ну и стандартные таймштампы
    а в Posts хранил бы счетчик голосований belongs_to :vote, :counter_cache => true

    Так бы и быстрее (без join) сразу доставать кол-во голосов за пост, ну и например проверять накрутки можно было бы.

    ОтветитьУдалить