Popular Posts

пятница, 13 апреля 2012 г.

Rails Autocomplete

Привет.
Сегодня рассмотрим автозаполнение (autocomplete) модная штука, с ajax. Для этого я буду использовать наработки. Помните пост, где делали теги? Вот именно для них я буду и делать автозаполнение.
И так у нас есть модель пост, с добавлеными туда тегами.
class Post < ActiveRecord::Base
    acts_as_taggable_on :tags
end
Конечно там больше кода, но важно видеть, что я не обманываю и там действительно есть теги.
Хорошо, теперь добавляем в контроллер метод для который будет отдавать все теги:
class PostsController < ApplicationController
  def get_tags
    @tags = Post.tag_counts.order('count DESC').limit(3).where('tags.name LIKE ?', "%#{params[:q]}%")
    render :get_tags, :layout => false
  end
end

Здесь простой запрос  к методу tag_counts, который автоматически генерируется acts_as_taggable_on, потом я ограничиваю количество тегов до 3-х, хотя можно и не ограничивать, и буду искать все имена тегов (tags.name), которые похожи на %params[:q]%,
это говорит о том, что искомый тег может начинаться и заканчиваться с любого символа. То есть  я буду отправлять запрос вида :q => "a", и вот эта буква "а" может встречаться в любом месте имени.
Ответ как видим не требует всего layout, потому что иначе будет отправлена в ответе вся страница со всеми файлами и прочим, нам же нужны только теги.

Добавляем, теперь этот самый шаблон страницы.
#views/posts/get_tags.html.erb
<ul>
<% @tags.each do |tag| %>
    <li><%= tag.name %></li>
<% end %>   
</ul>
Тут как видите просто, оборачиваем имя тега (name) в элементы li.
Я забыл сказать, что буду использовать Ajax.Autocompleter от Script.aculo.us. Вот, он требует такого ответа, хотя лучше ответ в json, но это тут не важно.

Затем добавляем в форму нового поста, поле для автозаполнения:
#views/posts/_form.html.erb
<%= form_for(@post) do |f| %>
  #несколько полей

<div class="field">
    <%= f.label 'Tag List' %><br />
    <%= f.text_field :tag_list, {:id => "autocomplete"} %>
    <div data-url="<%= url_for(get_tags_posts_path) %>" id="autocomplete_choices" class="autocomplete"></div>
</div>

<% end %>

Здесь я добавил атрибут data-url к полю для автозаполнения, этот url будет генерироваться автоматически, именно по этому адресу нужно обращаться для получения тегов.

Не забываем указать в ability.rb, кому можно получать теги:
#models.ability.rb
def initialize(user)
 #код

   if user.role?(:user)
        can :get_tags, Post

 #код
end
Это говорит о том, что теги могут получить только вошедшие в систему пользователи.
Так теперь добавим роутинг.
#config/routes.rb
resources :posts do
  #....

   post :get_tags, :on => :collection
end

Всё.

Осталось написать небольшой js-файл для обработки всего.
Сделайте файл в assets/javascripts/autocomplete.js
Вот нужный код

$(document).observe('dom:loaded', function() {
 var token = $$('input[name=authenticity_token]')[0];
 var url = $('autocomplete_choices').readAttribute('data-url');
 new Ajax.Autocompleter("autocomplete", 
                           "autocomplete_choices", url, 
                           {
                            paramName: "q",
                            tokens: [','],
                            requestHeaders: {'X-CSRF-Token': token.getValue()}
                           });

});

Переменная token нужна для предъявления серверу токена пользователя, это для защиты.
Потом мы читаем data-url, который добавили, помните? Это адрес для отправки запроса.
Потом сам Ajax.Autocompleter. У него первый аргумент -- это поле из которого нужно брать символы, второй это поле в которое будет писаться ответ, а потом идут параметры.
paramName: "q", q - это то, что будет в params, которое читаем в контроллере.
tokens: [','], это важный параметр, указывает разделитель между тегами, если его не будет, то будет на автозаполнении только первый самый тег, остальных нет.
requestHeaders, указывает дополнительный заголовок для сервера, тут отправляем token.getValue(), это значение находится в скрытом поле. Можете в исходном коде страницы посмотреть. Без этого заголовка, сервер не принимает нас, и перенаправляет на страницу входа, можете попробывать.
Теперь, нужно же добавить файл, так как автозаполнение нужно будет всего на двух страницах, странице создания и редактирования пост, то добавляем в _form.html.erb и всё.

<%= javascript_include_tag "autocomplete" %>

<%= form_for(@post) do |f| %>

#ваш код
Конечно это вставит код посреди страницы, но это не страшно.
Так последнее действие это добавление в загрузку нужных файлов.
#assets/javascripts/application.js

//
//= require scriptaculous/lib/prototype
//= require scriptaculous/src/effects
//= require scriptaculous/src/controls

Как видно я зугружая библиотеку прототип и нужные файлы из script.aculo.us -- эффекты и контролы. Поэтому разместите папку scriptaculous в assets/javascripts.

Ах да не забудьте добавить стили:

div.autocomplete {
  position:absolute;
  width:250px;
  background-color:white;
  border:1px solid #888;
  margin:0;
  padding:0;
}
div.autocomplete ul {
  list-style-type:none;
  margin:0;
  padding:0;
}
div.autocomplete ul li.selected { background-color: #ffb;}
div.autocomplete ul li {
  list-style-type:none;
  display:block;
  margin:0;
  padding:2px;
  height:32px;
  cursor:pointer;
} 

Ну или напишите свои, эти со страницы autocomplete.


суббота, 31 марта 2012 г.

Поиск в Rails (search in rails), используем sunspot

Привет

Давайте добавим поиск, у нас уже есть Post, Comment по которым можно что-то искать.
Для поиска я буду использовать Solr, который форкнут для rails Sunsop Solr.
и так начнм с установки в наш Gemfile:
gem 'sunspot_rails' #форк для rails
gem 'sunspot_solr'  #сам Solr
gem 'progress_bar'  #нужен для индексирования
group :test, :development do #в группу для разработки и тестирования
  gem "sunspot-rails-tester" #это нужно для запуска тестов, иначе не работает.
  #...
end
Ну и делаем bundle install, для установки gem'ов.
Хорошо теперь в модели нашей в Post, по которой и будет поиск добавляем след. блок:
class Post < ActiveRecord::Base
  after_create :reindex!
  after_update :reindex!

  searchable do
    text :title, :content
    integer :user_id
    time    :created_at
  
    string :sort_title do
     title.downcase.gsub(/^(an?|the)/, '')
    end
  end
  #...
  
  protected

    def reindex!
      Sunspot.index!(self)
    end
end
Блок searchable нужен для описания наших полей, тут можно добавить больше или наоборот убрать больше читайте в вики.
Тип text указывает, что это текстовые поля, time -- время. Здесь понятно, этот блок нужен для полнотекстового поиска по модели.
Также я добавил два callback'а, это нужно для того, чтобы когда мы добавим новый пост, был произведён переиндексирования, тоже
самое и для обновления поста. Какие здесь подводные камни? Нет переиндексации для комментариев, да?
Так теперь сделаем отдельный контроллер для поиска, хотя можно использовать и PostsController, но я лучше вынесу в отдельный
контроллер.
rails g controller search
#controllers/searc_controller.rb
class SearchController < ApplicationController

  def search
    @post = Post.search {
      keywords params[:query]
      order_by :created_at, :asc
    }.results
    render 'posts/index'
  end
end
Поиск делается методом search, который принимает блок, что в блоке, смотрите в вики, мне хватило, чтобы было
отсортировано по дате, а слова поиска указываются в параметрах. Ну и так как своего представления нет для
этого контроллера, то показываем на index из контроллера Posts.
Теперь сделаем форму поиска:
#posts/_search.html.erb
<%= form_tag search_path, :method => :get do %>
  <p>
    <%= text_field_tag :query, params[:query] %>
    <br />
    <%= submit_tag "Search!" %>
  </p>
<% end %>
Вот простая форма для поиска.


Теперь добавим для тестов несколько строк:
#spec/spec_helper.rb
$original_sunspot_session = Sunspot.session

RSpec.configure do |config|
 
  config.before do
    Sunspot.session = Sunspot::Rails::StubSessionProxy.new($original_sunspot_session)
  end
    config.before :each, :solr => true do
    Sunspot::Rails::Tester.start_original_sunspot_session
    Sunspot.session = $original_sunspot_session
    Sunspot.remove_all!
  end
  #......
  #......
  #......
end
Вот теперь всё прекрасно.
Как запускать?
rake sunspot:solr:start
#=>java version "1.6.0_23"
   OpenJDK Runtime Environment (IcedTea6 1.11pre) (6b23~pre11-0ubuntu1.11.10.2)
   OpenJDK Server VM (build 20.0-b11, mixed mode)
   Removing stale PID file at /home/mike/app/example-app/solr/pids/development/sunspot-solr-development.pid
   Successfully started Solr ...

Как остановаить?
rake sunspot:solr:stop
#=>java version "1.6.0_23"
   OpenJDK Runtime Environment (IcedTea6 1.11pre) (6b23~pre11-0ubuntu1.11.10.2)
   OpenJDK Server VM (build 20.0-b11, mixed mode)
   Successfully stopped Solr ...



сам gem на гитхабе
тоже
вики
railscasts

Полиморфные отношения. (Polymorphic Association)


Привет.
Сегодня рассмотрим как использовтаь полиморфные отношения в rails.
С чем это можно сравнить? Помните в детсвет у вас был карандаш\фломастер, которым
вы изрисовали пол\обои\стол\руки. Так вот карандаш -- это и есть полиморф, то есть рисует и на полу и на столе.
Так и здесь это такое отношение, которое может быть для многих типов.
Там во второй части я сделал модель для голосования, помните?
Вот это и будет наша полимофрная модель Vote.
и так сперва делаем миграцию:
rails g migration add_type_into_vote
Пишем в получившийся класс:
class AddTypeIntoVote < ActiveRecord::Migration
  def change
    change_table :votes do |f|
      f.string :votable_type
    end
    rename_column :votes, :post_id, :votable_id 
  end
end
Здесь как видно добавляем колонку для типа, здесь будет записан тип к которому применяется
модель. У нас это Post и Comment. То есть позволяет ставить голоса за комментарии и за посты.
Теперь исправим классы моделей.
#models/vote.rb
class Vote < ActiveRecord::Base
   belongs_to :votable, :polymorphic => true
   #....
end
Это описывает полиморфную модель, указываем строчкой polymorphic => true, то что это полиморфная модель,
а также смотрите belongs_to, не к post\comment, а к votable.
Затем мы могли бы добавить в каждую модель (Post, Comment) одинаковый код:
after_create :create_vote
has_one :vote, :dependent => :destroy, :as => :votable

def create_vote
  self.vote = Vote.create(:score => 0)
end
Видите, это должно быть в каждой модели, но это нарушает принцип DRY. Поэтому создаём модуль в директории с моделями:
#models/vote_module.rb
module VoteModule
  def self.included(base)
    base.module_eval {
      after_create :create_vote
      has_one :vote, :dependent => :destroy, :as => :votable
    }
  end

  protected
    def create_vote
      self.vote = Vote.create(:score => 0)
    end
end
Тут применено метапрограммирование self.included(base)
base - это класс в который подмешивается модуль.
self.included будет срабатывать после вставки в класс, и включать макросы after_create и has_one.
Смотрите в has_one указывается не votable, а vote, но то что это полиморфная модель говорит опция :as
Теперь это нужно включить в классы Post и Comment
#models/post.rb
class Post < ActiveRecord::Base
  include VoteModule
   #....
end
-----
#models/comment.rb
class Comment < ActiveRecord::Base
  include VoteModule
  #...
end
Хорошая техника, правда?
Так хорошо, исправляем контроллер:
#controllers/vote_controller.rb
class VoteController < ApplicationController
  load_and_authorize_resource
  before_filter :type
 
  def update
    vote = @type.vote 
    vote.score += 1 if params[:vote] == "Up"
    vote.score -= 1 if params[:vote] == "Down"
    vote.users << current_user
    vote.save

    message = "You vote counted!"
    redirect(message)
  end

  def clear
    vote.update_attributes(:score => 0)
    message = "Clear!"
    redirect(message)
  end

  rescue_from CanCan::AccessDenied do |exception|
    message = "You have already voted"
    redirect(message)
  end
 
  private
   
    def type
      @type = Post.find(params[:post_id])
      @type = Comment.find(params[:comment_id]) if params.include?(:comment_id)
    end
   
    def redirect(msg)
      respond_to do |format|
        format.text { render text: msg }
      end
    end

end
Как-то так, получилось (я уже забыл на самом деле, что там, и этот контроллер уже переписан, но про это в след. постах)
Однако тут мы сперва используем фильт before_filter, чтобы установить тип модели, или это пост или комментарий. Вот и всё
что важно в этом контроллере.
Представление исправте сами (ДЗ, ага). Не забывайте про роутинг:
#config/routes.rb
resources :posts do
    resources :vote, :only => [:update] do
      delete 'clear'
    end
   
    resources :comments, :only => [:create, :destroy] do
      resources :vote, :only => [:update] do
        delete 'clear'
      end
    end
end
Получается Vote вложен в post и comment.
Когда будете формировать линки для учитывайте это, посмотрите, что должно быть rake routes.
Вот как бы и всё про полиморфные модели, больше информации в api.

четверг, 15 марта 2012 г.

Devise + Cancan. Часть три

Привет.
Продолжим с devise и cancan.
Что будем делать?
Допустим я хочу сделать так чтобы даже не вошедший пользователь мог бы сделать пост, нет
 он его не опубликует, просто его перекинет на страницу входа. Вот.
Для этого делаем контроллер таким:
#controllers/post_controller.rb
class PostsController < ApplicationController
 
  before_filter :authenticate_user!, :only => [:new]
  load_and_authorize_resource
  skip_authorize_resource :only => :new 
  #...
end
Как видно тут добавлен before_filter и метод :authenticate_user!, который только для действия new.
Метод :authenticate_user! он определён в devise.
Что он делает?
Если пользователь вошёл, то ничего (на самом деле он проверяет)
Если не произведён вход на сайт, то будет перенаправлен (redirect_to) на страницу входа /sign_in
Далее стандартная load_and_authorize_resource из cancan
Затем снова метод из cancan skip_authorize_resource :only => :new который сбрасывает проверку привилегий для
метода (:new).
Вот достаточно просто.
Ах да ещё убираем из view нашу проверку
#views/posts/index.html.erb
<%= link_to 'New Post', new_post_path %>
убрали can? перед ссылкой на создание нового поста.

Что дальше?
Для системы, которую делаю, неплохо бы добавить к постам комментарии, это же оживляет страницу)
Создаём модель\контроллер\и сразу миграцию
rails g model comment post:references user:references body:text
rails g controller comments
rake db:migrate
Хоп! Всё сделано, остался код + тесты
В класс модели User добавляем след. строчку:
#models/user.rb
class User < ActiveRecord::Base
# ...
has_many :comments, :dependent => :destroy
end
Этот макрос отношение один-ко-многим, то есть у одного пользователя может быть сотня-другая комментраиев.
В модель Post
#models/post.rb
class Post < ActiveRecord::Base
#...
has_many :comments, :dependent => :destroy
end
То же что и для User у одного поста много-много комментариев, с удалением поста все они тоже будут удалены.
Ну и далее нужная нам новая модель Comment
#models/comment.rb
class Comment < ActiveRecord::Base
  belongs_to :post
  belongs_to :user
  validates :user_id, :post_id, :body, :presence => true
  attr_accessible :body
end
Первые методы belongs_to указываеют на связь к таблицам posts\users.
С validates понятно у комментария должен быть пользователь\пост\ну и собственно сам текст комментария.
attr_accessible -- (та строчка, из-за которой была паника на GitHub) указывает на то, что можно изменять только это поле (:body), но
не пользователя и пост.
Затем добавляем роутинг.
#/config/routes.rb
resources :posts do
  resources :comments, :only => [:create, :destroy]
end
Тут вложенный ресурс comments ссылки будут выглядеть posts/post_id/comments/
То есть навигация по комментариям идёт уже в определённом посте.
Вот часть сделали. Теперь контроллер
#controllers/comments_controller.rb
class CommentsController < ApplicationController
  load_and_authorize_resource
  def create
    @post = Post.find(params[:post_id])
    comment = @post.comments.build(params[:comment])
    comment.user = current_user

    if comment.save
      flash[:notice] = 'Comment was successfully created.'
      redirect_to post_path(@post)
    else
      flash[:notice] = 'The comment you typed was invalid.'
      render "posts/show"
    end
  end

  def destroy
    post = Post.find([params[:post_id]])
    comment = Comment.find(params[:id])
    comment.delete
    flash[:notice] = "Delete"
    redirect_to post_path(post)
  end

  rescue_from CanCan::AccessDenied do |exception|
    flash[:notice] = "You can not create or delete comment"
    redirect_to post_path(Post.find(params[:post_id]))
  end
end
В контроллере есть методы destroy, который удаляет комментарий и метод create, который создаёт комментарий.
Также видна строчка из cancan load_and_authorize_resource, которую я добавил (а значит изменил и models/ability.rb)
Потом важная вещь переопределил исключение, когда действие не допустимо. Если помните такое же переопределение есть в
ApplicationController так вот там это исключение, если пользователь делает что-то в не своих правах, то будет переброшен
на root_url, а здесь просто возврашаем на тот же пост. Вот и всё.
Методы также просты и не должны вызывать непонимания (чуть что в комментариях задайте вопрос)

Так теперь Ability
#models/ability.rb
class Ability
#...
 def initialize(user)
   #...
   if user.role? :admin
     #...
     can [:create, :destroy], Comment
   else
    if user.role?(:user)
       can :create, Comment
    #...
   end
 end
end
Показал только то, что добавил. Обычный пользователь может комментировать, а админ плюс ко всему и удалять неугодные комментарии)))
Теперь с view.
Форма для создания комментария
#views/comments/_form.html.erb
<% if can? :create, Comment %>
  <%= form_for [@post, current_user.comments.new] do |f| %>
    <%= f.text_area :body, :size => "70%x10" %>
    <br/>
    <%= f.submit "add comment" %>
  <% end %>
<% end %> 
Снова метод can? который проверяет может ли пользователь создавать комментарий. Ну и потом стандартная форма.
Затем для вывода комментариев для поста:
#views/comments/_index.html.erb
<% @post.comments.each do |comment| %>
  <p>
    <b>
      Name:<%= comment.user.email %>
    </b>
    <%= comment.body %>
    <% if can? :clear, Vote %>
      <%= link_to 'delete', post_comment_path(@post, comment), method: :delete %>
    <% end %>
  </p> 
<% end %>
Тут просто выводим email пользователья (потом улучшим будет имя) ну и сам комментарий. А также ссылка для админа, который может удалить комментарий. Формирование ссылки посмотрите, как строится: если мы наберём
rake routes
то покажется куча роутов среди которых есть и post_comment_path, для которого нужен пост и коммент, что мы и передаём ему, а rails сам формирует правильную ссылку.
Теперь добавляем наши view в вывод поста:
#views/posts/show.html.erb
<%= render 'comments/index'%>
<%= render 'comments/form' %>
Просто добавте пару строчек и всё (после вывода поста конечно же, но можете и перед, как угодно).

Ну и теперь куча тестов:
Сначала для PostsController, там же добавили некоторые вещи.
#spec/controllers/post_controller_spec.rb
  describe "GET new" do
    before(:each) {
      @user = Factory(:user)
      @user.roles << Factory(:role) 
      sign_in @user
    }
    it "asigns a new post as @post" 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 auth" do
      get 'new'
      assigns(:post).should be_a_new(Post)
      response.should render_template('posts/new')
    end
    it "should redirect_to if user not auth" do
      sign_out @user
      get 'new'
      response.should redirect_to(new_user_session_path)
    end
  end
Надеюсь названия понятны и говорят сами за себя.
Ну и для CommentsController
/spec/controllers/comments_controller_spec.rb
require 'spec_helper'

describe CommentsController do
  describe "POST create" do
    before(:each) {
      @user = Factory(:user)
      @user.roles << Factory(:role)
      @post = Factory(:post, :user => @user)
    }
    it "should create new comment if user can create comments" do
      sign_in @user
      attr = {:body => "new comment"}
      post 'create', {:post_id => @post.id, :comment => attr }
      flash[:notice].should eq('Comment was successfully created.')
      response.should redirect_to(post_path(@post))  
    end
    it "should render posts/show if attr is invalid" do
      sign_in @user
      post 'create', {:post_id => @post.id, :comment => {}}
      flash[:notice].should eq('The comment you typed was invalid.')
      response.should render_template('posts/show')  
    end
    it "should not create new comment if user can not ability" do
      post 'create', {:post_id => @post.id, :comment => {}}
      flash[:notice].should eq("You can not create or delete comment")
      response.should redirect_to(post_path(@post))
    end
  end

  describe "DELETE destroy" do
    before(:each) {
      @post = Factory(:post, :user => Factory(:user))
      comment = Factory(:comment, :user => Factory(:user), :post => @post)
    }
    it "should delete comment if admin" do
      user = Factory(:user)
      user.roles << Factory(:role, :name => :admin)
     
      sign_in user
      
      @post.comments.count.should eq(1)
     
      delete 'destroy', { :post_id => @post.id, :id => @post.comments.last.id }
     
      flash[:notice].should eq("Delete")
      response.should redirect_to(post_path(@post))
      @post.comments.count.should eq(0)
    end
    it "should not delete comment if user" do
      user = Factory(:user)
      user.roles << Factory(:role)
     
      sign_in user
      
      @post.comments.count.should eq(1)
     
      delete 'destroy', { :post_id => @post.id, :id => @post.comments.last.id }
     
      flash[:notice].should eq("You can not create or delete comment")
      response.should redirect_to(post_path(@post))
      @post.comments.count.should eq(1)
    end
  end
end

вторник, 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 марта 2012 г.

Devise + Cancan -- быстрая разработка


Привет.
Сегодня посмотрим как работать с devise и cancan. Важные gem'ы, да?
и так сперва задача:

  •   Авторизация\регистрация\аутентификация пользователя на сайте -- это задача devise
  •   Потом привилегии: 
  •   админ может всё ;)
  • пользователь может создавать посты
  • автор постов (владелец) может его редактировать, но удалять не может.

Не очень сложно.
и так в Gemfile добавляем
gem 'devise'
gem 'cancan'

Ok, установили теперь devise:
rails g devise:install
Эта команда создаст файл инициализации, нам нужно его отредактировать:
 config/initializers/devise.rb
находим строчку
 config.sign_out_via = :delete
меняем на :get.
Так хорошо. Теперь делаем модель пользователя:
rails g devise user
Создаёт модель. Ну и сразу делаем миграцию, что создаёт таблицу в БД:
rake db:migrate
импортируем views
rails g devise:views
Ок. Теперь делаем для постов, тут легче:
rails g scaffold Post title:string content:text user:references
rake db:migrate
Всё! Что хотели, то сделали: можно регистрироваться и все дела.
Теперь время добавить привилегии с cancan:
rails g cancan:ability
Создаёт класс возможностей\привилегий в app/models.
Ну в принципе каркас написан, осталось добавить немного кода.
Но перед этим нужны же ещё Роли (Role). У пользователя может быть тысяча ролей, и модератор и просто_пользователь в общем придумайте сами.
У меня -- это будет admin\user и всё. и так как видно это отношение has_many:through


rails g model role name:string
rails g model users_role user:references role:references


Всё теперь редактируем модель User, добавляем зависимости:
class User < ActiveRecord::Base
  has_many :users_roles
  has_many :roles, :through => :users_roles
end

Модель Role
class Role < ActiveRecord::Base
  has_many :users_roles
  has_many :users, :through => :users_roles
end


Всего лишь строчку в PostController:
load_and_authorize_resource
и всё.
Теперь с представлением:
Ну допустим раньше выводилось вот так:
#views/index.html.erb
<% @posts.each do |post| %>
  <tr>
    <td><%= post.title %></td>
    <td><%= post.content %></td>
    <td><%= post.user %></td>
    <td><%= link_to 'Show', post %></td>
    <td><%= link_to 'Edit', edit_post_path(post) %></td>
    <td><%= link_to 'Destroy', post, confirm: 'Are you sure?', method: :delete %></td>
  </tr>
<% end %>
<br />
<%= link_to 'New Post', new_post_path %>
Теперь с помощью волшебного метода can делаем:
<h1>Listing posts</h1>


<table>
  <tr>
    <th>Title</th>
    <th>Content</th>
    <th>User</th>
    <th></th>
    <th></th>
    <th></th>
  </tr>


<% @posts.each do |post| %>
  <tr>
    <td><%= post.title %></td>
    <td><%= post.content %></td>
    <td><%= post.user %></td>
    <% if can? :read, post %>
      <td><%= link_to 'Show', post %></td>
    <% end %>
    <% if can? :update, post %>
      <td><%= link_to 'Edit', edit_post_path(post) %></td>
    <% end %>
    <% if can? :destroy, post %>
      <td><%= link_to 'Destroy', post, confirm: 'Are you sure?', method: :delete %></td>
    <% end %>
  </tr>
<% end %>
</table>


<% if can? :create, Post %>
  <%= link_to 'New Post', new_post_path %>
<% end %>
Также не забывайте в контроллере PostsController в методе create устанавливать связь пользователя  и нового поста:
def create
    @post = current_user.posts.new(params[:post])
  ....
end

Потом напишем немного больше и продолжим с cancan + добавим спеки (rspec)
Немного UPD
Чтобы сделать меню, где вход\выход -- стандратное меню, нужно например в views/layouts/application.html.erb
добавить что-то вроде этого:

<div id="header">
  <%= render "shared/links" %>
</div>
Папку shared создайте сами, и в ней файл _links.html.erb

<ul>
  <% if user_signed_in? %>
    <li><%= link_to "My Profile", edit_user_registration_path%> </li>
    <li><%= link_to "Sign out", destroy_user_session_path%> </li>
  <% else %>
    <li><%= link_to "Sign up", new_user_registration_path%> </li>
    <li><%= link_to "Sign in", new_user_session_path%> </li>
  <% end %>
</ul>   

Потом для правильно отрисовки в app/helpers/application_helper.rb:

module ApplicationHelper
  def resource_name
    :user
  end


  def resource
    @resource ||= User.new
  end


  def devise_mapping
    @devise_mapping ||= Devise.mappings[:user]
  end
end

Ok, всё круто, но я чувствую у вас вопрос: Откуда взять роль, когда пользователь зарегистрировался? Ответ -- callbacks
В модели User:
#models/user.rb
class User < ActiveRecord::Base
   before_create :create_role
  
  has_many :posts #надеюсь вы не забыли установить эту зависимость :)
  private
    def create_role
      self.roles << Role.find_by_name(:user)  
    end
end
Ещё UPD
Когда мы ищём роль: Role.find_by_name(:user), то предпологается, что уже есть такая роль. Поэтому перед запуском приложения в файле db/seeds.rb
Role.create(:name => :admin)
Role.create(:name => :user)
И потом в консоли rake db:seed


UPD код: http://code.google.com/p/example-app/


Прошлый пост

вторник, 28 февраля 2012 г.

PrototypeJS Классы, Наследование и Модули.

Привет!
Сегодня важная тема, я бы сказал -- это наследование и классы и объекты в prototype, ну также всё, что будет показано относится в равной мере и ко всему javascript.

Проблема

Как всегда сначала описание проблемы. Предположим у меня есть мебель: шкаф и диван. Ну да немного, но для задачи достаточно. Как это выглядит со стороны объектов? Понятно, что и то, и то -- мебель, ведь так? Но на диване я сижу, лежу, и так далее, а в шкафу куча дверек, в шкаф я складываю одежду\посуду\да_что_угодно. Но также есть такие общие вещи как цвет. Вот.
Теперь непосредственно проблема, по идее диван и шкаф виды мебели, но диван и шкаф могут иметь разные свойста (для сиденье? сколько двёрок? и так далее). Как же это описать?
Вот тут-то и появляются так называемые MixIn, или модули, которые могут быть в новинку для javascript программистов, но отнюдь не новы для тех, кто пишет на ruby. Но благодаря, природе javascript в нём можно спокойно делать модули и разбавлять объекты. Я написал много слов, теперь напишу код.
И так. Сначала практика создания классов в prototypejs
Тут просто:
var SomeClass = Class.create({
    initialize: function(){}
});
Как просто, да? Сначала делаем класс Class.create, в нём нужна функция конструктор (но она не обязательна) initialize, что сродни ruby методу с таким же названием. Это функция будет инициализироваться при создании объекта. Всё просто.
Всё теперь пишу нужный код:
var Furniture = Class.create({
    initialize: function(title, price)
    {
      this.title = title;
      this.price = price;
    },
    toString: function()
    {
          return this.title + ": " + this.price + "$"
    }
    });
Две функции, инициализации, и вторая функция для вывода информации о мебели.
Этот класс в java я бы сделал абстрактным, потому что просто мебели как бы не существует, это абстаркция, мебель это всегда что-то -- стул, стол, шкаф...

Теперь пишем классы для Шкафа и Дивана:
var Sofa = Class.create(Furniture, {
    initialize: function($super, title, price)
    {
      $super(title, price);
    }
});
var Cupboard = Class.create(Furniture, {
    
    initialize: function($super, title, price)
    {
      $super(title, price);
    }
});
Как вы, надеюсь, поняли, здесь мы наследуем от мебели (Furniture) с помощью Class.create(ExtObj, {}), где ExtObj -- это "родитель". В инициализаторе вызываем $super, это
заставляет проинициализировать код функции родителя. Про $super, читайте в конце поста.

Ok, я это сделал, теперь время подмешать модули.
Пусть есть такие свойста: цвет, можно ли сидеть, сколько дверек в мебели.
//для цвета
var Color = {
      set_color: function(color)
      {
        this.color = color;
      },
      get_color: function()
      {
        return this.color;
      }
};
//Я могу сесть на эту мебель?
var ForSeating = {
    for_seating: function()
    {
      return true;
    }
};
//Сколько дверек в этой мебели?
var ManyDoors = {
    set_doors: function(doors)
    {
        his.doors = doors;
    },
       how_many: function()
    {
      return this.doors;
    }
};
Ok, круто, как видим все эти модули просты Object\ы  в javascript.
Теперь самое интересное и простое, как подмешать? Для этого есть методы Object, который расширяет Prototype -- addMethods()
Получаем:
//Для дивана модуль который говорит, что можно сидеть и модуль цвета
Sofa.addMethods(ForSeating);
Sofa.addMethods(Color);
//Для шкафа цвет и дверки.


Cupboard.addMethods(Color);
Cupboard.addMethods(ManyDoors);
Что получим?

Вот результаты:
Сначала создаём новые:
var sofa = new Sofa('Cool Sofa', 10);
var cupboard = new Cupboard('My Cupboard', 12);


//Устанавливаем цвета и двери для шкафа
sofa.set_color('red');

cupboard.set_color('black');
cupboard.set_doors(3);

//А вот результат уже
sofa.get_color(); // => 'red'
sofa.for_seating(); // => true
cupboard.get_color(); // => 'black'
cupboard.how_many(); // =>  3
Если мы вызовем для шкафа метод, который скажет можно ли на нём сидеть? то получим



Вот так.

Совсем маленький рефакторинг.

Как мы видили, Модуль Color добавляется и в Sofa и в Cupboard, поэтому лучше "подмешать" в родительский класс:

Furniture.addMethods(Color);

Теперь методы этого модуля будут доступны в обоих объектах.

Для чего всё это нужно?

Это позволяет вынести общие методы для разных объектов, что упростит жизнь, ведь кода нужно меньше, вы не будете повторять себя.
Сделает понятнее программу.
Какие возможные траблы?
Возможно затереть методы, очень часто есть методы set()\get(), которые могут быть реализованы для разных модулей, в итоге останется только один, как горец))). Также сложность следить за программой, у вас в модуле появится ссылка this, на объкт + куча свойств объекта, не сразу понятно как этим пользоваться, но это просто, так же в связи с этим можно случайно переопределить свойство, поэтому рекомендую не полениться и написать get_property()\set_property(), так лучше.

Я написал много слов об недостатках, но это для того, чтобы предупредить о сложности, на самом деле плюсов больше.

Примечание: Наследование PrototypeJS

Снова немного кода, для пояснения некоторых вещей.  Есть несколько классов:
var A = Class.create({
    say: function()
    {
      alert('A');
    },
    method: function()
    {
      alert('method A');
    }
    });
    var B = Class.create(A, {
    say: function()
    {
      alert('B');
    }
    });
    var C = Class.create(A, {
    say: function($super)
   {
      $super();
      alert('C');
    },
    abc: function()
    {
      alert('abc');
    }
    });
   
    var a = new A();
    var b = new B();
    var c = new C();
    a.say(); // => A
    b.say(); // => B
    c.say(); // => A, C
    c.method(); // => method A
Здесь видим наследование B и C наследует A. Но в B мы пишем свой метод say(). а в C вызываем сначала родительский $super() -- это пригодится, когда нужно установить некоторые свойста, которые определяются в родительском, например при инициализации. Также видим, что метод method() из A доступен и в C дочернем классе.  Доки