acts-as-rated 0.4
Sign up to get free protection for your applications and to get access to all the features.
- data/MIT-LICENSE +20 -0
- data/README +147 -0
- data/Rakefile +190 -0
- data/acts-as-rated.gemspec +20 -0
- data/init.rb +3 -0
- data/lib/acts_as_rated.rb +441 -0
- data/test/rated_test.rb +426 -0
- metadata +60 -0
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2007 Guy Naor (Famundo LLC)
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README
ADDED
@@ -0,0 +1,147 @@
|
|
1
|
+
= acts_as_rated
|
2
|
+
|
3
|
+
The ultimate rating system for ActiveRecord models. Highly flexible and configurable, while easy to use with the defaults. Supports 3 different ways to manage the statistics, and creates all the needed associations for easy access to everything.
|
4
|
+
|
5
|
+
Comes complete with the needed migrations code to make it easy to add to any project.
|
6
|
+
|
7
|
+
<em>NOTE:</em> It uses some advanced SQL constructs that might not be supported by all servers. It was tested on Postgres only. If you have patches/fixes for other databases, please send them and I will add them to the plugin. <em>UPDATE:</em> Thanks to work done by Tiago Serafim it now passes all but one tests on MySQL. And this test fails due to strangeness in the avg() function in MySQL, according to Tiago.
|
8
|
+
|
9
|
+
|
10
|
+
== Features
|
11
|
+
|
12
|
+
* Rate any model
|
13
|
+
* Optionally add fields to the rated objects to optimize speed
|
14
|
+
* Optionally add an external rating statistics table with a record for each rated model
|
15
|
+
* Can work with the added fields, external table or just using direct SQL count/avg calls
|
16
|
+
* Use any model as the rater (defaults to User)
|
17
|
+
* Limit the range of the ratings
|
18
|
+
* Average, total and number of ratings
|
19
|
+
* Find objects by ratings or rating ranges
|
20
|
+
* Find objects by rater
|
21
|
+
* Check if an object is rated by a specific rater. (Added by Tiago Serafim, thanks!)
|
22
|
+
* Extensively tested
|
23
|
+
|
24
|
+
== Basic Details
|
25
|
+
|
26
|
+
Install
|
27
|
+
|
28
|
+
* script/plugin install svn://rubyforge.org/var/svn/acts-as-rated/trunk/acts_as_rated
|
29
|
+
* gem install - <b>coming soon</b>
|
30
|
+
|
31
|
+
Rubyforge project
|
32
|
+
|
33
|
+
* http://rubyforge.org/projects/acts-as-rated
|
34
|
+
|
35
|
+
RDocs
|
36
|
+
|
37
|
+
* http://acts-as-rated.rubyforge.org
|
38
|
+
|
39
|
+
Subversion
|
40
|
+
|
41
|
+
* svn://rubyforge.org/var/svn/acts-as-rated
|
42
|
+
|
43
|
+
Agile Web Development directory
|
44
|
+
|
45
|
+
* http://www.agilewebdevelopment.com/plugins/acts_as_rated
|
46
|
+
|
47
|
+
My blog with some comments about the plugin
|
48
|
+
|
49
|
+
* http://devblog.famundo.com
|
50
|
+
|
51
|
+
Work done as part of Famundo development
|
52
|
+
|
53
|
+
* http://www.famundo.com
|
54
|
+
|
55
|
+
Contact me at
|
56
|
+
|
57
|
+
* guy.naor@famundo.com
|
58
|
+
|
59
|
+
== Changes
|
60
|
+
* V3 - Added improved find_by_rating as proposed by Ian McIntosh
|
61
|
+
* V2 - Passing MySQL tests, check if rated by a specific rater as proposed by Tiago Serafim
|
62
|
+
|
63
|
+
== TODO
|
64
|
+
* Test with more databases
|
65
|
+
* Test with other versions of Rails (tested against 1.2.1)
|
66
|
+
* Add view helpers for easy display and entering of the ratings
|
67
|
+
|
68
|
+
== Example of usage:
|
69
|
+
|
70
|
+
=== Simple rating system
|
71
|
+
Look at the file <tt>test/rating_test.rb</tt> for many usage examples covering all variations of the plugin.
|
72
|
+
|
73
|
+
class Book < ActiveRecord::Base
|
74
|
+
acts_as_rated
|
75
|
+
end
|
76
|
+
|
77
|
+
bill = User.find_by_name 'bill'
|
78
|
+
jill = User.find_by_name 'jill'
|
79
|
+
catch22 = Book.find_by_title 'Catch 22'
|
80
|
+
hobbit = Book.find_by_title 'Hobbit'
|
81
|
+
|
82
|
+
catch22.rate 5, bill
|
83
|
+
hobbit.rate 3, bill
|
84
|
+
catch22.rate 1, jill
|
85
|
+
hobbit.rate 5, jill
|
86
|
+
|
87
|
+
hobbit.rating_average # => 4
|
88
|
+
hobbit.rated_total # => 8
|
89
|
+
hobbit.rated_count # => 2
|
90
|
+
|
91
|
+
hobbit.unrate bill
|
92
|
+
hobbit.rating_average # => 5
|
93
|
+
hobbit.rated_total # => 5
|
94
|
+
hobbit.rated_count # => 1
|
95
|
+
|
96
|
+
bks = Book.find_by_rating 5 # => [hobbit]
|
97
|
+
bks = Book.find_by_rating 1..5 # => [catch22, hobbit]
|
98
|
+
|
99
|
+
usr = Book.find_rated_by jill # => [catch22, hobbit]
|
100
|
+
|
101
|
+
=== Migration
|
102
|
+
The file <tt>test/fixtures/migrations/001_add_rating_tables.rb</tt> shows examples of all types of migration options.
|
103
|
+
|
104
|
+
See also the detailed documentation for the <tt>acts_as_rated</tt> method on how to declare it, and the rest of the documentation for how to generate the migration columns/files and how to use it.
|
105
|
+
|
106
|
+
class AddRatingTables < ActiveRecord::Migration
|
107
|
+
def self.up
|
108
|
+
ActiveRecord::Base.create_ratings_table
|
109
|
+
|
110
|
+
# Movies table has the columns for the ratings added while it's created
|
111
|
+
create_table(:movies) do |t|
|
112
|
+
t.column :title, :text
|
113
|
+
Movie.generate_ratings_columns t
|
114
|
+
end
|
115
|
+
|
116
|
+
# Cars table has the columns for the ratings added, but after the fact, using ALTER TABLE calls.
|
117
|
+
# Usually used if the model already exist and we want to add the ratings after the fact
|
118
|
+
create_table(:cars) do |t|
|
119
|
+
t.column :title, :text
|
120
|
+
end
|
121
|
+
Car.add_ratings_columns
|
122
|
+
end
|
123
|
+
|
124
|
+
def self.down
|
125
|
+
# Remove the columns we added
|
126
|
+
Car.remove_ratings_columns
|
127
|
+
|
128
|
+
drop_table :movies rescue nil
|
129
|
+
drop_table :cars rescue nil
|
130
|
+
|
131
|
+
ActiveRecord::Base.drop_ratings_table
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
|
136
|
+
== Testing the plugin
|
137
|
+
|
138
|
+
The plugin comes with a full set of tests, both for migrations and for the code itself. The framework was taken from the acts_as_versioned plugin, allowing it to run stand-alone in the test directory.
|
139
|
+
|
140
|
+
run the tests:
|
141
|
+
rake test
|
142
|
+
|
143
|
+
In order for testing to work, you need to create a database (default name is acts_as_rated_plugin_test) and edit test/database.yml to make sure the login and password are correct. You can also change there the name of the database.
|
144
|
+
|
145
|
+
Testing defaults to postgresql, to change it set the environment variable DB to the driver you want to use:
|
146
|
+
env DB='mysql' rake test
|
147
|
+
|
data/Rakefile
ADDED
@@ -0,0 +1,190 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
|
3
|
+
Gem::manage_gems
|
4
|
+
|
5
|
+
require 'rake/rdoctask'
|
6
|
+
require 'rake/packagetask'
|
7
|
+
require 'rake/gempackagetask'
|
8
|
+
require 'rake/testtask'
|
9
|
+
require 'rake/contrib/rubyforgepublisher'
|
10
|
+
|
11
|
+
PKG_NAME = 'acts_as_rated'
|
12
|
+
PKG_VERSION = '0.2.0'
|
13
|
+
PKG_FILE_NAME = "#{PKG_NAME}-#{PKG_VERSION}"
|
14
|
+
PROD_HOST = "guy.naor@famundo.com"
|
15
|
+
RUBY_FORGE_PROJECT = 'acts-as-rated'
|
16
|
+
RUBY_FORGE_USER = 'guynaor'
|
17
|
+
|
18
|
+
desc 'Default: run all tests.'
|
19
|
+
task :default => :test
|
20
|
+
|
21
|
+
task :test => [:test_plugin, :test_migrations ]
|
22
|
+
|
23
|
+
desc 'Test the acts_as_rated plugin.'
|
24
|
+
Rake::TestTask.new(:test_plugin) do |t|
|
25
|
+
t.libs << 'lib'
|
26
|
+
t.pattern = 'test/rated_test.rb'
|
27
|
+
t.verbose = true
|
28
|
+
end
|
29
|
+
|
30
|
+
desc 'Test the acts_as_rated plugin.'
|
31
|
+
Rake::TestTask.new(:test_migrations) do |t|
|
32
|
+
t.libs << 'lib'
|
33
|
+
t.pattern = 'test/migration_test.rb'
|
34
|
+
t.verbose = true
|
35
|
+
end
|
36
|
+
|
37
|
+
desc 'Generate documentation for the acts_as_rated plugin.'
|
38
|
+
Rake::RDocTask.new(:rdoc) do |rdoc|
|
39
|
+
rdoc.rdoc_dir = 'doc'
|
40
|
+
rdoc.title = "#{PKG_NAME} -- Rating system for ActiveRecord models"
|
41
|
+
rdoc.options << '--line-numbers'
|
42
|
+
rdoc.options << '--inline-source'
|
43
|
+
rdoc.rdoc_files.include('README')
|
44
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
45
|
+
end
|
46
|
+
|
47
|
+
spec = Gem::Specification.new do |s|
|
48
|
+
s.name = PKG_NAME
|
49
|
+
s.version = PKG_VERSION
|
50
|
+
s.platform = Gem::Platform::RUBY
|
51
|
+
s.summary = "Rating system for active record models"
|
52
|
+
s.files = FileList["{lib,test}/**/*"].to_a + %w(README MIT-LICENSE)
|
53
|
+
s.files.delete "test/debug.log"
|
54
|
+
s.require_path = 'lib'
|
55
|
+
s.autorequire = 'acts_as_versioned'
|
56
|
+
s.has_rdoc = true
|
57
|
+
s.test_files = Dir['test/**/*_test.rb']
|
58
|
+
s.add_dependency 'activerecord', '>= 1.10.1'
|
59
|
+
s.add_dependency 'activesupport', '>= 1.1.1'
|
60
|
+
s.author = "Guy Naor"
|
61
|
+
s.email = "guy.naor@famundo.com"
|
62
|
+
s.homepage = "http://devblog.famundo.com"
|
63
|
+
end
|
64
|
+
|
65
|
+
Rake::GemPackageTask.new(spec) do |pkg|
|
66
|
+
pkg.need_tar = true
|
67
|
+
end
|
68
|
+
|
69
|
+
desc "Publish the API documentation"
|
70
|
+
task :pdoc => [:rdoc] do
|
71
|
+
Rake::RubyForgePublisher.new(RUBY_FORGE_PROJECT, RUBY_FORGE_USER).upload
|
72
|
+
end
|
73
|
+
|
74
|
+
desc 'Publish the gem and API docs'
|
75
|
+
task :publish => [:pdoc, :rubyforge_upload]
|
76
|
+
|
77
|
+
desc "Publish the release files to RubyForge."
|
78
|
+
task :rubyforge_upload => :package do
|
79
|
+
files = %w(gem tgz).map { |ext| "pkg/#{PKG_FILE_NAME}.#{ext}" }
|
80
|
+
|
81
|
+
if RUBY_FORGE_PROJECT then
|
82
|
+
require 'net/http'
|
83
|
+
require 'open-uri'
|
84
|
+
|
85
|
+
project_uri = "http://rubyforge.org/projects/#{RUBY_FORGE_PROJECT}/"
|
86
|
+
project_data = open(project_uri) { |data| data.read }
|
87
|
+
group_id = project_data[/[?&]group_id=(\d+)/, 1]
|
88
|
+
raise "Couldn't get group id" unless group_id
|
89
|
+
|
90
|
+
# This echos password to shell which is a bit sucky
|
91
|
+
if ENV["RUBY_FORGE_PASSWORD"]
|
92
|
+
password = ENV["RUBY_FORGE_PASSWORD"]
|
93
|
+
else
|
94
|
+
print "#{RUBY_FORGE_USER}@rubyforge.org's password: "
|
95
|
+
password = STDIN.gets.chomp
|
96
|
+
end
|
97
|
+
|
98
|
+
login_response = Net::HTTP.start("rubyforge.org", 80) do |http|
|
99
|
+
data = [
|
100
|
+
"login=1",
|
101
|
+
"form_loginname=#{RUBY_FORGE_USER}",
|
102
|
+
"form_pw=#{password}"
|
103
|
+
].join("&")
|
104
|
+
http.post("/account/login.php", data)
|
105
|
+
end
|
106
|
+
|
107
|
+
cookie = login_response["set-cookie"]
|
108
|
+
raise "Login failed" unless cookie
|
109
|
+
headers = { "Cookie" => cookie }
|
110
|
+
|
111
|
+
release_uri = "http://rubyforge.org/frs/admin/?group_id=#{group_id}"
|
112
|
+
release_data = open(release_uri, headers) { |data| data.read }
|
113
|
+
package_id = release_data[/[?&]package_id=(\d+)/, 1]
|
114
|
+
raise "Couldn't get package id" unless package_id
|
115
|
+
|
116
|
+
first_file = true
|
117
|
+
release_id = ""
|
118
|
+
|
119
|
+
files.each do |filename|
|
120
|
+
basename = File.basename(filename)
|
121
|
+
file_ext = File.extname(filename)
|
122
|
+
file_data = File.open(filename, "rb") { |file| file.read }
|
123
|
+
|
124
|
+
puts "Releasing #{basename}..."
|
125
|
+
|
126
|
+
release_response = Net::HTTP.start("rubyforge.org", 80) do |http|
|
127
|
+
release_date = Time.now.strftime("%Y-%m-%d %H:%M")
|
128
|
+
type_map = {
|
129
|
+
".zip" => "3000",
|
130
|
+
".tgz" => "3110",
|
131
|
+
".gz" => "3110",
|
132
|
+
".gem" => "1400"
|
133
|
+
}; type_map.default = "9999"
|
134
|
+
type = type_map[file_ext]
|
135
|
+
boundary = "rubyqMY6QN9bp6e4kS21H4y0zxcvoor"
|
136
|
+
|
137
|
+
query_hash = if first_file then
|
138
|
+
{
|
139
|
+
"group_id" => group_id,
|
140
|
+
"package_id" => package_id,
|
141
|
+
"release_name" => PKG_FILE_NAME,
|
142
|
+
"release_date" => release_date,
|
143
|
+
"type_id" => type,
|
144
|
+
"processor_id" => "8000", # Any
|
145
|
+
"release_notes" => "",
|
146
|
+
"release_changes" => "",
|
147
|
+
"preformatted" => "1",
|
148
|
+
"submit" => "1"
|
149
|
+
}
|
150
|
+
else
|
151
|
+
{
|
152
|
+
"group_id" => group_id,
|
153
|
+
"release_id" => release_id,
|
154
|
+
"package_id" => package_id,
|
155
|
+
"step2" => "1",
|
156
|
+
"type_id" => type,
|
157
|
+
"processor_id" => "8000", # Any
|
158
|
+
"submit" => "Add This File"
|
159
|
+
}
|
160
|
+
end
|
161
|
+
|
162
|
+
query = "?" + query_hash.map do |(name, value)|
|
163
|
+
[name, URI.encode(value)].join("=")
|
164
|
+
end.join("&")
|
165
|
+
|
166
|
+
data = [
|
167
|
+
"--" + boundary,
|
168
|
+
"Content-Disposition: form-data; name=\"userfile\"; filename=\"#{basename}\"",
|
169
|
+
"Content-Type: application/octet-stream",
|
170
|
+
"Content-Transfer-Encoding: binary",
|
171
|
+
"", file_data, ""
|
172
|
+
].join("\x0D\x0A")
|
173
|
+
|
174
|
+
release_headers = headers.merge(
|
175
|
+
"Content-Type" => "multipart/form-data; boundary=#{boundary}"
|
176
|
+
)
|
177
|
+
|
178
|
+
target = first_file ? "/frs/admin/qrs.php" : "/frs/admin/editrelease.php"
|
179
|
+
http.post(target + query, data, release_headers)
|
180
|
+
end
|
181
|
+
|
182
|
+
if first_file then
|
183
|
+
release_id = release_response.body[/release_id=(\d+)/, 1]
|
184
|
+
raise("Couldn't get release id") unless release_id
|
185
|
+
end
|
186
|
+
|
187
|
+
first_file = false
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
Gem::Specification.new do |s|
|
2
|
+
s.name = "acts-as-rated"
|
3
|
+
s.version = "0.4"
|
4
|
+
s.date = "2009-01-08"
|
5
|
+
s.summary = "Rails plugin rating system for ActiveRecord models."
|
6
|
+
s.email = "guy.naor@famundo.com"
|
7
|
+
s.homepage = "http://github.com/jasherai/acts-as-rated"
|
8
|
+
s.description = "Flexible, configurable, and easy to use with the defaults. Supports 3 different ways to manage rating statistics."
|
9
|
+
s.has_rdoc = false
|
10
|
+
s.authors = "Guy Noar"
|
11
|
+
s.files = [
|
12
|
+
"acts-as-rated.gemspec",
|
13
|
+
"init.rb",
|
14
|
+
"lib/acts_as_rated.rb",
|
15
|
+
"MIT-LICENSE",
|
16
|
+
"Rakefile",
|
17
|
+
"README"
|
18
|
+
]
|
19
|
+
s.test_files = ["test/rated_test.rb"]
|
20
|
+
end
|
data/init.rb
ADDED
@@ -0,0 +1,441 @@
|
|
1
|
+
module ActiveRecord #:nodoc:
|
2
|
+
module Acts #:nodoc:
|
3
|
+
|
4
|
+
# == acts_as_rated
|
5
|
+
# Adds rating capabilities to any ActiveRecord object.
|
6
|
+
# It has the ability to work with objects that have or don't special fields to keep a tally of the
|
7
|
+
# ratings and number of votes for each object.
|
8
|
+
# In addition it will by default use the User model as the rater object and keep the ratings per-user.
|
9
|
+
# It can be configured to use another class, or not use a rater at all, just keeping a global rating
|
10
|
+
#
|
11
|
+
# Special methods are provided to create the ratings table and if needed, to add the special fields needed
|
12
|
+
# to keep per-objects ratings fast for access to rated objects. Can be easily used in migrations.
|
13
|
+
#
|
14
|
+
# == Example of usage:
|
15
|
+
#
|
16
|
+
# class Book < ActiveRecord::Base
|
17
|
+
# acts_as_rated
|
18
|
+
# end
|
19
|
+
#
|
20
|
+
# bill = User.find_by_name 'bill'
|
21
|
+
# jill = User.find_by_name 'jill'
|
22
|
+
# catch22 = Book.find_by_title 'Catch 22'
|
23
|
+
# hobbit = Book.find_by_title 'Hobbit'
|
24
|
+
#
|
25
|
+
# catch22.rate 5, bill
|
26
|
+
# hobbit.rate 3, bill
|
27
|
+
# catch22.rate 1, jill
|
28
|
+
# hobbit.rate 5, jill
|
29
|
+
#
|
30
|
+
# hobbit.rating_average # => 4
|
31
|
+
# hobbit.rated_total # => 8
|
32
|
+
# hobbit.rated_count # => 2
|
33
|
+
#
|
34
|
+
# hobbit.unrate bill
|
35
|
+
# hobbit.rating_average # => 5
|
36
|
+
# hobbit.rated_total # => 5
|
37
|
+
# hobbit.rated_count # => 1
|
38
|
+
#
|
39
|
+
# bks = Book.find_by_rating 5 # => [hobbit]
|
40
|
+
# bks = Book.find_by_rating 1..5 # => [catch22, hobbit]
|
41
|
+
#
|
42
|
+
# usr = Book.find_rated_by jill # => [catch22, hobbit]
|
43
|
+
#
|
44
|
+
module Rated
|
45
|
+
|
46
|
+
class RateError < RuntimeError; end
|
47
|
+
|
48
|
+
def self.included(base) #:nodoc:
|
49
|
+
base.extend(ClassMethods)
|
50
|
+
end
|
51
|
+
|
52
|
+
module ClassMethods
|
53
|
+
|
54
|
+
# Make the model ratable. Can work both with and without a rater entity (defaults to User).
|
55
|
+
# The Rating model, holding the details of the ratings, will be created dynamically if it doesn't exist.
|
56
|
+
#
|
57
|
+
# * Adds a <tt>has_many :ratings</tt> association to the model for easy retrieval of the detailed ratings.
|
58
|
+
# * Adds a <tt>has_many :raters</tt> association to the onject, unless <tt>:no_rater</tt> is given as a configuration parameter.
|
59
|
+
# * Adds a <tt>has_many :ratings</tt> associations to the rater class.
|
60
|
+
# * Adds a <tt>has_one :rating_statistic</tt> association to the model, if <tt>:with_stats_table => true</tt> is given as a configuration param.
|
61
|
+
#
|
62
|
+
# === Options
|
63
|
+
# * <tt>:rating_class</tt> -
|
64
|
+
# class of the model used for the ratings. Defaults to Rating. This class will be dynamically created if not already defined.
|
65
|
+
# If the class is predefined, it must have in it the following definitions:
|
66
|
+
# <tt>belongs_to :rated, :polymorphic => true</tt> and if using a rater (which is true in most cases, see below) also
|
67
|
+
# <tt>belongs_to :rater, :class_name => 'User', :foreign_key => :rater_id</tt> replace user with the rater class if needed.
|
68
|
+
# * <tt>:rater_class</tt> -
|
69
|
+
# class of the model that creates the rating.
|
70
|
+
# Defaults to User This class will NOT be created, so it must be defined in the app.
|
71
|
+
# Another option will be to keep a session or IP based ID here to prevent multiple ratings from the same client.
|
72
|
+
# * <tt>:no_rater</tt> -
|
73
|
+
# do not keep track of who created the rating. This will change the behaviour
|
74
|
+
# to one that just collects and averages ratings, but doesn't keep track of who
|
75
|
+
# posted the rating. Useful in a public application that doesn't care about
|
76
|
+
# individual votes
|
77
|
+
# * <tt>:rating_range</tt> -
|
78
|
+
# A range object for the acceptable rating value range. Defaults to not limited
|
79
|
+
# * <tt>:with_stats_table</tt> -
|
80
|
+
# Use a separate statistics table to hold the count/total/average rating of the rated object instead of adding the columns to the object's table.
|
81
|
+
# This means we do not have to change the model table. It still holds a big performance advantage over using SQL to get the statistics
|
82
|
+
# * <tt>:stats_class -
|
83
|
+
# Class of the statics table model. Only needed if <tt>:with_stats_table</tt> is set to true. Default to RatingStat.
|
84
|
+
# This class need to have the following defined: <tt>belongs_to :rated, :polymorphic => true</tt>.
|
85
|
+
# And must make sure that it has the attributes <tt>rating_count</tt>, <tt>rating_total</tt> and <tt>rating_avg</tt> and those
|
86
|
+
# must be initialized to 0 on new instances
|
87
|
+
#
|
88
|
+
def acts_as_rated(options = {})
|
89
|
+
# don't allow multiple calls
|
90
|
+
return if self.included_modules.include?(ActiveRecord::Acts::Rated::RateMethods)
|
91
|
+
send :include, ActiveRecord::Acts::Rated::RateMethods
|
92
|
+
|
93
|
+
# Create the model for ratings if it doesn't yet exist
|
94
|
+
rating_class = options[:rating_class] || 'Rating'
|
95
|
+
rater_class = options[:rater_class] || 'User'
|
96
|
+
stats_class = options[:stats_class] || 'RatingStatistic' if options[:with_stats_table]
|
97
|
+
|
98
|
+
unless Object.const_defined?(rating_class)
|
99
|
+
Object.class_eval <<-EOV
|
100
|
+
class #{rating_class} < ActiveRecord::Base
|
101
|
+
belongs_to :rated, :polymorphic => true
|
102
|
+
#{options[:no_rater] ? '' : "belongs_to :rater, :class_name => #{rater_class}, :foreign_key => :rater_id"}
|
103
|
+
end
|
104
|
+
EOV
|
105
|
+
end
|
106
|
+
|
107
|
+
unless stats_class.nil? || Object.const_defined?(stats_class)
|
108
|
+
Object.class_eval <<-EOV
|
109
|
+
class #{stats_class} < ActiveRecord::Base
|
110
|
+
belongs_to :rated, :polymorphic => true
|
111
|
+
end
|
112
|
+
EOV
|
113
|
+
end
|
114
|
+
|
115
|
+
raise RatedError, ":rating_range must be a range object" unless options[:rating_range].nil? || (Range === options[:rating_range])
|
116
|
+
write_inheritable_attribute( :acts_as_rated_options ,
|
117
|
+
{ :rating_range => options[:rating_range],
|
118
|
+
:rating_class => rating_class,
|
119
|
+
:stats_class => stats_class,
|
120
|
+
:rater_class => rater_class } )
|
121
|
+
class_inheritable_reader :acts_as_rated_options
|
122
|
+
|
123
|
+
class_eval do
|
124
|
+
has_many :ratings, :as => :rated, :dependent => :delete_all, :class_name => rating_class.to_s
|
125
|
+
has_many(:raters, :through => :ratings, :class_name => rater_class.to_s) unless options[:no_rater]
|
126
|
+
has_one(:rating_statistic, :class_name => stats_class.to_s, :as => :rated, :dependent => :delete) unless stats_class.nil?
|
127
|
+
|
128
|
+
before_create :init_rating_fields
|
129
|
+
end
|
130
|
+
|
131
|
+
# Add to the User (or whatever the rater is) a has_many ratings if working with a rater
|
132
|
+
return if options[:no_rater]
|
133
|
+
rater_as_class = rater_class.constantize
|
134
|
+
return if rater_as_class.instance_methods.include?('find_in_ratings')
|
135
|
+
rater_as_class.class_eval <<-EOS
|
136
|
+
has_many :ratings, :foreign_key => :rater_id, :class_name => #{rating_class.to_s}
|
137
|
+
EOS
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
module RateMethods
|
142
|
+
|
143
|
+
def self.included(base) #:nodoc:
|
144
|
+
base.extend ClassMethods
|
145
|
+
end
|
146
|
+
|
147
|
+
# Get the average based on the special fields,
|
148
|
+
# or with a SQL query if the rated objects doesn't have the avg and count fields
|
149
|
+
def rating_average
|
150
|
+
return self.rating_avg if attributes.has_key?('rating_avg')
|
151
|
+
return (rating_statistic.rating_avg || 0) rescue 0 if acts_as_rated_options[:stats_class]
|
152
|
+
avg = ratings.average(:rating)
|
153
|
+
avg = 0 if avg.nan?
|
154
|
+
avg
|
155
|
+
end
|
156
|
+
|
157
|
+
# Is this object rated already?
|
158
|
+
def rated?
|
159
|
+
return (!self.rating_count.nil? && self.rating_count > 0) if attributes.has_key? 'rating_count'
|
160
|
+
if acts_as_rated_options[:stats_class]
|
161
|
+
stats = (rating_statistic.rating_count || 0) rescue 0
|
162
|
+
return stats > 0
|
163
|
+
end
|
164
|
+
|
165
|
+
# last is the one where we don't keep the statistics - go direct to the db
|
166
|
+
!ratings.find(:first).nil?
|
167
|
+
end
|
168
|
+
|
169
|
+
# Get the number of ratings for this object based on the special fields,
|
170
|
+
# or with a SQL query if the rated objects doesn't have the avg and count fields
|
171
|
+
def rated_count
|
172
|
+
return self.rating_count || 0 if attributes.has_key? 'rating_count'
|
173
|
+
return (rating_statistic.rating_count || 0) rescue 0 if acts_as_rated_options[:stats_class]
|
174
|
+
ratings.count
|
175
|
+
end
|
176
|
+
|
177
|
+
# Get the sum of all ratings for this object based on the special fields,
|
178
|
+
# or with a SQL query if the rated objects doesn't have the avg and count fields
|
179
|
+
def rated_total
|
180
|
+
return self.rating_total || 0 if attributes.has_key? 'rating_total'
|
181
|
+
return (rating_statistic.rating_total || 0) rescue 0 if acts_as_rated_options[:stats_class]
|
182
|
+
ratings.sum(:rating)
|
183
|
+
end
|
184
|
+
|
185
|
+
# Rate the object with or without a rater - create new or update as needed
|
186
|
+
#
|
187
|
+
# * <tt>value</tt> - the value to rate by, if a rating range was specified will be checked that it is in range
|
188
|
+
# * <tt>rater</tt> - an object of the rater class. Must be valid and with an id to be used.
|
189
|
+
# If the acts_as_rated was passed :with_rater => false, this parameter is not required
|
190
|
+
def rate value, rater = nil
|
191
|
+
# Sanity checks for the parameters
|
192
|
+
rating_class = acts_as_rated_options[:rating_class].constantize
|
193
|
+
with_rater = rating_class.column_names.include? "rater_id"
|
194
|
+
raise RateError, "rating with no rater cannot accept a rater as a parameter" if !with_rater && !rater.nil?
|
195
|
+
if with_rater && !(acts_as_rated_options[:rater_class].constantize === rater)
|
196
|
+
raise RateError, "the rater object must be the one used when defining acts_as_rated (or a descendent of it). other objects are not acceptable"
|
197
|
+
end
|
198
|
+
raise RateError, "rating with rater must receive a rater as parameter" if with_rater && (rater.nil? || rater.id.nil?)
|
199
|
+
r = with_rater ? ratings.find(:first, :conditions => ['rater_id = ?', rater.id]) : nil
|
200
|
+
raise RateError, "value is out of range!" unless acts_as_rated_options[:rating_range].nil? || acts_as_rated_options[:rating_range] === value
|
201
|
+
|
202
|
+
# Find the place to store the rating statistics if any...
|
203
|
+
# Take care of the case of a separate statistics table
|
204
|
+
unless acts_as_rated_options[:stats_class].nil? || @rating_statistic.class.to_s == acts_as_rated_options[:stats_class]
|
205
|
+
self.rating_statistic = acts_as_rated_options[:stats_class].constantize.new
|
206
|
+
end
|
207
|
+
target = self if attributes.has_key? 'rating_total'
|
208
|
+
target ||= self.rating_statistic if acts_as_rated_options[:stats_class]
|
209
|
+
rating_class.transaction do
|
210
|
+
if r.nil?
|
211
|
+
rate = rating_class.new
|
212
|
+
rate.rater_id = rater.id if with_rater
|
213
|
+
if target
|
214
|
+
target.rating_count = (target.rating_count || 0) + 1
|
215
|
+
target.rating_total = (target.rating_total || 0) + value
|
216
|
+
target.rating_avg = target.rating_total.to_f / target.rating_count
|
217
|
+
end
|
218
|
+
ratings << rate
|
219
|
+
else
|
220
|
+
rate = r
|
221
|
+
if target
|
222
|
+
target.rating_total += value - rate.rating # Update the total rating with the new one
|
223
|
+
target.rating_avg = target.rating_total.to_f / target.rating_count
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
# Remove the actual ratings table entry
|
228
|
+
rate.rating = value
|
229
|
+
if !new_record?
|
230
|
+
rate.save
|
231
|
+
target.save if target
|
232
|
+
end
|
233
|
+
end
|
234
|
+
end
|
235
|
+
|
236
|
+
# Unrate the rating of the specified rater object.
|
237
|
+
# * <tt>rater</tt> - an object of the rater class. Must be valid and with an id to be used
|
238
|
+
#
|
239
|
+
# Unrate cannot be called for acts_as_rated with :with_rater => false
|
240
|
+
def unrate rater
|
241
|
+
rating_class = acts_as_rated_options[:rating_class].constantize
|
242
|
+
if !(acts_as_rated_options[:rater_class].constantize === rater)
|
243
|
+
raise RateError, "The rater object must be the one used when defining acts_as_rated (or a descendent of it). other objects are not acceptable"
|
244
|
+
end
|
245
|
+
raise RateError, "Rater must be a valid and existing object" if rater.nil? || rater.id.nil?
|
246
|
+
raise RateError, 'Cannot unrate if not using a rater' if !rating_class.column_names.include? "rater_id"
|
247
|
+
r = ratings.find(:first, :conditions => ['rater_id = ?', rater.id])
|
248
|
+
if !r.nil?
|
249
|
+
target = self if attributes.has_key? 'rating_total'
|
250
|
+
target ||= self.rating_statistic if acts_as_rated_options[:stats_class]
|
251
|
+
if target
|
252
|
+
rating_class.transaction do
|
253
|
+
target.rating_count -= 1
|
254
|
+
target.rating_total -= r.rating
|
255
|
+
target.rating_avg = target.rating_total.to_f / target.rating_count
|
256
|
+
target.rating_avg = 0 if target.rating_avg.nan?
|
257
|
+
end
|
258
|
+
end
|
259
|
+
|
260
|
+
# Removing the ratings table entry
|
261
|
+
r.destroy
|
262
|
+
target.save if !target.nil?
|
263
|
+
end
|
264
|
+
end
|
265
|
+
|
266
|
+
# Check if an item was already rated by the given rater
|
267
|
+
def rated_by? rater
|
268
|
+
rating_class = acts_as_rated_options[:rating_class].constantize
|
269
|
+
if !(acts_as_rated_options[:rater_class].constantize === rater)
|
270
|
+
raise RateError, "The rater object must be the one used when defining acts_as_rated (or a descendent of it). other objects are not acceptable"
|
271
|
+
end
|
272
|
+
raise RateError, "Rater must be a valid and existing object" if rater.nil? || rater.id.nil?
|
273
|
+
raise RateError, 'Rater must be a valid rater' if !rating_class.column_names.include? "rater_id"
|
274
|
+
ratings.count(:conditions => ['rater_id = ?', rater.id]) > 0
|
275
|
+
end
|
276
|
+
|
277
|
+
private
|
278
|
+
|
279
|
+
def init_rating_fields #:nodoc:
|
280
|
+
if attributes.has_key? 'rating_total'
|
281
|
+
self.rating_count ||= 0
|
282
|
+
self.rating_total ||= 0
|
283
|
+
self.rating_avg ||= 0
|
284
|
+
end
|
285
|
+
end
|
286
|
+
|
287
|
+
end
|
288
|
+
|
289
|
+
module ClassMethods
|
290
|
+
|
291
|
+
# Generate the ratings columns on a table, to be used when creating the table
|
292
|
+
# in a migration. This is the preferred way to do in a migration that creates
|
293
|
+
# new tables as it will make it as part of the table creation, and not generate
|
294
|
+
# ALTER TABLE calls after the fact
|
295
|
+
def generate_ratings_columns table
|
296
|
+
table.column :rating_count, :integer
|
297
|
+
table.column :rating_total, :decimal
|
298
|
+
table.column :rating_avg, :decimal, :precision => 10, :scale => 2
|
299
|
+
end
|
300
|
+
|
301
|
+
# Create the needed columns for acts_as_rated.
|
302
|
+
# To be used during migration, but can also be used in other places.
|
303
|
+
def add_ratings_columns
|
304
|
+
if !self.column_names.include? 'rating_count'
|
305
|
+
self.connection.add_column table_name, :rating_count, :integer
|
306
|
+
self.connection.add_column table_name, :rating_total, :decimal
|
307
|
+
self.connection.add_column table_name, :rating_avg, :decimal, :precision => 10, :scale => 2
|
308
|
+
self.reset_column_information
|
309
|
+
end
|
310
|
+
end
|
311
|
+
|
312
|
+
# Remove the acts_as_rated specific columns added with add_ratings_columns
|
313
|
+
# To be used during migration, but can also be used in other places
|
314
|
+
def remove_ratings_columns
|
315
|
+
if self.column_names.include? 'rating_count'
|
316
|
+
self.connection.remove_columns table_name, :rating_count, :rating_total, :rating_avg
|
317
|
+
self.reset_column_information
|
318
|
+
end
|
319
|
+
end
|
320
|
+
|
321
|
+
# Create the ratings table
|
322
|
+
# === Options hash:
|
323
|
+
# * <tt>:with_rater</tt> - add the rated_id column
|
324
|
+
# * <tt>:table_name</tt> - use a table name other than ratings
|
325
|
+
# * <tt>:with_stats_table</tt> - create also a rating statistics table
|
326
|
+
# * <tt>:stats_table_name</tt> - the name of the rating statistics table. Defaults to :rating_statistics
|
327
|
+
# To be used during migration, but can also be used in other places
|
328
|
+
def create_ratings_table options = {}
|
329
|
+
with_rater = options[:with_rater] != false
|
330
|
+
name = options[:table_name] || :ratings
|
331
|
+
stats_table = options[:stats_table_name] || :rating_statistics if options[:with_stats_table]
|
332
|
+
self.connection.create_table(name) do |t|
|
333
|
+
t.column(:rater_id, :integer) unless !with_rater
|
334
|
+
t.column :rated_id, :integer
|
335
|
+
t.column :rated_type, :string
|
336
|
+
t.column :rating, :decimal
|
337
|
+
end
|
338
|
+
|
339
|
+
self.connection.add_index(name, :rater_id) unless !with_rater
|
340
|
+
self.connection.add_index name, [:rated_type, :rated_id]
|
341
|
+
|
342
|
+
unless stats_table.nil?
|
343
|
+
self.connection.create_table(stats_table) do |t|
|
344
|
+
t.column :rated_id, :integer
|
345
|
+
t.column :rated_type, :string
|
346
|
+
t.column :rating_count, :integer
|
347
|
+
t.column :rating_total, :decimal
|
348
|
+
t.column :rating_avg, :decimal, :precision => 10, :scale => 2
|
349
|
+
end
|
350
|
+
|
351
|
+
self.connection.add_index stats_table, [:rated_type, :rated_id]
|
352
|
+
end
|
353
|
+
|
354
|
+
end
|
355
|
+
|
356
|
+
# Drop the ratings table.
|
357
|
+
# === Options hash:
|
358
|
+
# * <tt>:table_name</tt> - the name of the ratings table, defaults to ratings
|
359
|
+
# * <tt>:with_stats_table</tt> - remove the special rating statistics as well
|
360
|
+
# * <tt>:stats_table_name</tt> - the statistics table name. Defaults to :rating_statistics
|
361
|
+
# To be used during migration, but can also be used in other places
|
362
|
+
def drop_ratings_table options = {}
|
363
|
+
name = options[:table_name] || :ratings
|
364
|
+
stats_table = options[:stats_table_name] || :rating_statistics if options[:with_stats_table]
|
365
|
+
self.connection.drop_table name
|
366
|
+
self.connection.drop_table stats_table unless stats_table.nil?
|
367
|
+
end
|
368
|
+
|
369
|
+
# Find all ratings for a specific rater.
|
370
|
+
# Will raise an error if this acts_as_rated is without a rater.
|
371
|
+
def find_rated_by rater
|
372
|
+
rating_class = acts_as_rated_options[:rating_class].constantize
|
373
|
+
raise RateError, "The rater object must be the one used when defining acts_as_rated (or a descendent of it). other objects are not acceptable" if !(acts_as_rated_options[:rater_class].constantize === rater)
|
374
|
+
raise RateError, 'Cannot find_rated_by if not using a rater' if !rating_class.column_names.include? "rater_id"
|
375
|
+
raise RateError, "Rater must be an existing object with an id" if rater.id.nil?
|
376
|
+
rated_class = ActiveRecord::Base.send(:class_name_of_active_record_descendant, self).to_s
|
377
|
+
conds = [ 'rated_type = ? AND rater_id = ?', rated_class, rater.id ]
|
378
|
+
acts_as_rated_options[:rating_class].constantize.find(:all, :conditions => conds).collect {|r| r.rated_type.constantize.find_by_id r.rated.id }
|
379
|
+
end
|
380
|
+
|
381
|
+
|
382
|
+
# Find by rating - pass either a specific value or a range and the precision to calculate with
|
383
|
+
# * <tt>value</tt> - the value to look for or a range
|
384
|
+
# * <tt>precision</tt> - number of decimal digits to round to. Default to 10. Use 0 for integer numbers comparision
|
385
|
+
# * <tt>round_it</tt> - round the rating average before comparing?. Defaults to true. Passing false will result in a faster query
|
386
|
+
def find_by_rating value, precision = 10, round = true
|
387
|
+
rating_class = acts_as_rated_options[:rating_class].constantize
|
388
|
+
if column_names.include? "rating_avg"
|
389
|
+
if Range === value
|
390
|
+
conds = round ? [ 'round(rating_avg, ?) BETWEEN ? AND ?', precision.to_i, value.begin, value.end ] :
|
391
|
+
[ 'rating_avg BETWEEN ? AND ?', value.begin, value.end ]
|
392
|
+
else
|
393
|
+
conds = round ? [ 'round(rating_avg, ?) = ?', precision.to_i, value ] : [ 'rating_avg = ?', value ]
|
394
|
+
end
|
395
|
+
find :all, :conditions => conds
|
396
|
+
else
|
397
|
+
if round
|
398
|
+
base_sql = <<-EOS
|
399
|
+
select #{table_name}.*,round(COALESCE(average,0), #{precision.to_i}) AS rating_average from #{table_name} left outer join
|
400
|
+
(select avg(rating) as average, rated_id
|
401
|
+
from #{rating_class.table_name}
|
402
|
+
where rated_type = '#{class_name}'
|
403
|
+
group by rated_id) as rated
|
404
|
+
on rated_id=id
|
405
|
+
EOS
|
406
|
+
else
|
407
|
+
base_sql = <<-EOS
|
408
|
+
select #{table_name}.*,COALESCE(average,0) AS rating_average from #{table_name} left outer join
|
409
|
+
(select avg(rating) as average, rated_id
|
410
|
+
from #{rating_class.table_name}
|
411
|
+
where rated_type = '#{class_name}'
|
412
|
+
group by rated_id) as rated
|
413
|
+
on rated_id=id
|
414
|
+
EOS
|
415
|
+
end
|
416
|
+
if Range === value
|
417
|
+
if round
|
418
|
+
where_part = " WHERE round(COALESCE(average,0), #{precision.to_i}) BETWEEN #{connection.quote(value.begin)} AND #{connection.quote(value.end)}"
|
419
|
+
else
|
420
|
+
where_part = " WHERE COALESCE(average,0) BETWEEN #{connection.quote(value.begin)} AND #{connection.quote(value.end)}"
|
421
|
+
end
|
422
|
+
else
|
423
|
+
if round
|
424
|
+
where_part = " WHERE round(COALESCE(average,0), #{precision.to_i}) = #{connection.quote(value)}"
|
425
|
+
else
|
426
|
+
where_part = " WHERE COALESCE(average,0) = #{connection.quote(value)}"
|
427
|
+
end
|
428
|
+
end
|
429
|
+
|
430
|
+
find_by_sql base_sql + where_part
|
431
|
+
end
|
432
|
+
end
|
433
|
+
end
|
434
|
+
|
435
|
+
end
|
436
|
+
end
|
437
|
+
end
|
438
|
+
|
439
|
+
|
440
|
+
ActiveRecord::Base.send :include, ActiveRecord::Acts::Rated
|
441
|
+
|
data/test/rated_test.rb
ADDED
@@ -0,0 +1,426 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), 'abstract_unit')
|
2
|
+
require File.join(File.dirname(__FILE__), 'dummy_classes')
|
3
|
+
|
4
|
+
class RatedTest < Test::Unit::TestCase
|
5
|
+
fixtures :cars, :movies, :books, :users, :ratings, :no_rater_ratings, :videos, :stats_ratings, :my_stats_ratings, :rating_statistics, :my_statistics
|
6
|
+
|
7
|
+
def test_rate
|
8
|
+
# Regular one...
|
9
|
+
m = movies(:gone_with_the_wind)
|
10
|
+
check_average m, 4.33
|
11
|
+
m.rate 1, users(:sarah)
|
12
|
+
check_average m, 3
|
13
|
+
m = Movie.new :title => 'King Kong'
|
14
|
+
m.rate 4, users(:john)
|
15
|
+
assert m.new_record?
|
16
|
+
assert_equal 4, m.rating_average
|
17
|
+
assert_equal 1, m.rating_count
|
18
|
+
assert_equal 4, m.rating_total
|
19
|
+
assert m.save
|
20
|
+
m = Movie.find m.id
|
21
|
+
assert_equal 4, m.rating_average
|
22
|
+
assert_equal 1, m.rating_count
|
23
|
+
assert_equal 4, m.rating_total
|
24
|
+
m.rate 6, users(:bill)
|
25
|
+
m.rate 2, users(:sarah)
|
26
|
+
assert_equal 4, m.rating_average
|
27
|
+
assert_equal 3, m.rating_count
|
28
|
+
assert_equal 12, m.rating_total
|
29
|
+
assert_raise(ActiveRecord::Acts::Rated::RateError) { m.rate 6 }
|
30
|
+
|
31
|
+
# Ratring with norating columns
|
32
|
+
b = books(:shogun)
|
33
|
+
assert_raise(NoMethodError) { b.rating_total }
|
34
|
+
check_average b, 3.75
|
35
|
+
b.rate 10, Worker.find(users(:jane).id)
|
36
|
+
check_average b, 5.5
|
37
|
+
|
38
|
+
# Rating with no rater
|
39
|
+
c = cars(:bug)
|
40
|
+
check_average c, 4
|
41
|
+
assert_raise(ActiveRecord::Acts::Rated::RateError) { c.rate 10, users(:jill) }
|
42
|
+
c.rate 10
|
43
|
+
c.rate 10
|
44
|
+
c.rate 10
|
45
|
+
c.rate 10
|
46
|
+
c.rate 10
|
47
|
+
check_average c, 9
|
48
|
+
|
49
|
+
# Ranged ratings
|
50
|
+
f = Film.find :first, :order => 'title'
|
51
|
+
assert_equal 'Crash', f.title
|
52
|
+
assert_raise(ActiveRecord::Acts::Rated::RateError) { f.rate 0, users(:sarah) }
|
53
|
+
assert_raise(ActiveRecord::Acts::Rated::RateError) { f.rate 5.0001, users(:sarah) }
|
54
|
+
f.rate 1, users(:sarah)
|
55
|
+
f.rate 5, users(:jane)
|
56
|
+
check_average f, 3
|
57
|
+
|
58
|
+
# rating with an external statistics table
|
59
|
+
v = videos(:ten)
|
60
|
+
rc = v.ratings.count
|
61
|
+
assert_raise(NoMethodError) { v.rating_total }
|
62
|
+
check_average v, 1
|
63
|
+
v.rate 9, users(:jane)
|
64
|
+
check_average v, 3
|
65
|
+
assert_equal rc, v.ratings.count
|
66
|
+
v.rate 3, users(:jack)
|
67
|
+
check_average v, 3
|
68
|
+
assert_equal rc + 1, v.ratings.count
|
69
|
+
t = Tape.find(videos(:fame).id)
|
70
|
+
rc = t.ratings.count
|
71
|
+
assert_raise(NoMethodError) { t.rating_total }
|
72
|
+
check_average t, 5
|
73
|
+
t.rate 2, users(:jane)
|
74
|
+
check_average t, 4
|
75
|
+
assert_equal rc, t.ratings.count
|
76
|
+
t.rate 8, users(:jack)
|
77
|
+
check_average t, 5
|
78
|
+
assert_equal rc + 1, t.ratings.count
|
79
|
+
v = Video.new :title => 'Hair'
|
80
|
+
v.save
|
81
|
+
check_average v, 0
|
82
|
+
v.rate 4, users(:bill)
|
83
|
+
check_average v, 4
|
84
|
+
t = Tape.new :title => 'Friends'
|
85
|
+
t.save
|
86
|
+
check_average t, 0
|
87
|
+
t.rate 4, users(:bill)
|
88
|
+
check_average t, 4
|
89
|
+
t.rate 6, users(:jill)
|
90
|
+
check_average t, 5
|
91
|
+
|
92
|
+
# Rating with the wrong rater class or one that's not initialized
|
93
|
+
assert_raise(ActiveRecord::Acts::Rated::RateError) { b.rate 10, users(:jane) }
|
94
|
+
assert_raise(ActiveRecord::Acts::Rated::RateError) { b.rate 10, 3 }
|
95
|
+
assert_raise(ActiveRecord::Acts::Rated::RateError) { b.rate 10, Worker.new }
|
96
|
+
assert_raise(ActiveRecord::Acts::Rated::RateError) { b.rate 10 }
|
97
|
+
end
|
98
|
+
|
99
|
+
def test_unrate
|
100
|
+
# Regular one...
|
101
|
+
m = movies(:gone_with_the_wind)
|
102
|
+
check_average m, 4.33
|
103
|
+
assert_raise(ActiveRecord::Acts::Rated::RateError) { m.unrate nil }
|
104
|
+
m.unrate users(:john)
|
105
|
+
m.unrate users(:bill)
|
106
|
+
m.unrate users(:sarah)
|
107
|
+
m.unrate users(:jane)
|
108
|
+
m.unrate users(:jill)
|
109
|
+
check_average m, 0
|
110
|
+
m = Movie.new :title => 'King Kong'
|
111
|
+
m.rate 4, users(:john)
|
112
|
+
m.rate 4, users(:bill)
|
113
|
+
assert m.new_record?
|
114
|
+
assert_equal 4, m.rating_average
|
115
|
+
assert_equal 2, m.rating_count
|
116
|
+
assert_equal 8, m.rating_total
|
117
|
+
assert m.save
|
118
|
+
m = Movie.find m.id
|
119
|
+
assert_equal 4, m.rating_average
|
120
|
+
assert_equal 2, m.rating_count
|
121
|
+
assert_equal 8, m.rating_total
|
122
|
+
m.unrate users(:john)
|
123
|
+
assert_equal 4, m.rating_average
|
124
|
+
assert_equal 1, m.rating_count
|
125
|
+
assert_equal 4, m.rating_total
|
126
|
+
|
127
|
+
# Unrating with norating columns
|
128
|
+
b = books(:shogun)
|
129
|
+
assert_raise(NoMethodError) { b.ratings[0].rating_total }
|
130
|
+
check_average b, 3.75
|
131
|
+
b.unrate Worker.find(users(:bill).id)
|
132
|
+
check_average b, 4
|
133
|
+
|
134
|
+
# Unrating with external stats table
|
135
|
+
v = videos(:fields_of_dreams)
|
136
|
+
check_average v, 3.2
|
137
|
+
assert_raise(ActiveRecord::Acts::Rated::RateError) { v.unrate nil }
|
138
|
+
v.unrate users(:john)
|
139
|
+
v.unrate users(:bill)
|
140
|
+
v.unrate users(:sarah)
|
141
|
+
v.unrate users(:jane)
|
142
|
+
v.unrate users(:jill)
|
143
|
+
check_average v, 0
|
144
|
+
v = Video.new :title => 'King Kong'
|
145
|
+
assert v.new_record?
|
146
|
+
assert v.save
|
147
|
+
v = Video.find v.id
|
148
|
+
v.rate 4, users(:john)
|
149
|
+
v.rate 4, users(:bill)
|
150
|
+
assert_equal 4, v.rating_average
|
151
|
+
assert_equal 2, v.rated_count
|
152
|
+
assert_equal 8, v.rated_total
|
153
|
+
v.unrate users(:john)
|
154
|
+
assert_equal 4, v.rating_average
|
155
|
+
assert_equal 1, v.rated_count
|
156
|
+
assert_equal 4, v.rated_total
|
157
|
+
|
158
|
+
t = Tape.find(videos(:fields_of_dreams).id)
|
159
|
+
check_average t, 3.2
|
160
|
+
assert_raise(ActiveRecord::Acts::Rated::RateError) { t.unrate nil }
|
161
|
+
t.unrate users(:john)
|
162
|
+
t.unrate users(:bill)
|
163
|
+
t.unrate users(:sarah)
|
164
|
+
t.unrate users(:jane)
|
165
|
+
t.unrate users(:jill)
|
166
|
+
check_average t, 0
|
167
|
+
t = Tape.new :title => 'Scream'
|
168
|
+
assert t.save
|
169
|
+
t.rate 4, users(:john)
|
170
|
+
t.rate 6, users(:bill)
|
171
|
+
t = Tape.find t.id
|
172
|
+
assert_equal 5, t.rating_average
|
173
|
+
assert_equal 2, t.rated_count
|
174
|
+
assert_equal 10, t.rated_total
|
175
|
+
t.unrate users(:john)
|
176
|
+
assert_equal 6, t.rating_average
|
177
|
+
assert_equal 1, t.rated_count
|
178
|
+
assert_equal 6, t.rated_total
|
179
|
+
|
180
|
+
# No unrating with no rater
|
181
|
+
c = cars(:bug)
|
182
|
+
assert_raise(ActiveRecord::Acts::Rated::RateError) { c.unrate users(:jill) }
|
183
|
+
assert_raise(ActiveRecord::Acts::Rated::RateError) { c.unrate nil }
|
184
|
+
|
185
|
+
# Check unrater validity
|
186
|
+
b = books(:shogun)
|
187
|
+
assert_raise(ActiveRecord::Acts::Rated::RateError) { b.unrate users(:jane) }
|
188
|
+
assert_raise(ActiveRecord::Acts::Rated::RateError) { b.unrate 3 }
|
189
|
+
assert_raise(ActiveRecord::Acts::Rated::RateError) { b.unrate Worker.new }
|
190
|
+
end
|
191
|
+
|
192
|
+
def test_rated?
|
193
|
+
[Car, Movie, Book, Video, Tape, Truck, Film].each do |c|
|
194
|
+
# First check all the ones we have in the fixtures
|
195
|
+
c.find(:all).each do |o|
|
196
|
+
assert o.rated? if o.rated_count > 0
|
197
|
+
end
|
198
|
+
|
199
|
+
# Then create some new ones and test those as well
|
200
|
+
o = c.new(:title => 'Test Title')
|
201
|
+
assert o.save
|
202
|
+
assert !o.rated?
|
203
|
+
o.rate 4, Worker.find(users(:john).id) if [Book].include? c
|
204
|
+
o.rate 4, users(:john) if [Movie, Video, Tape, Film].include? c
|
205
|
+
o.rate 4 if [Car, Truck].include? c
|
206
|
+
#o.reload
|
207
|
+
assert o.rated?
|
208
|
+
end
|
209
|
+
end
|
210
|
+
|
211
|
+
def test_rating_average
|
212
|
+
m = movies(:gone_with_the_wind)
|
213
|
+
check_average m, 4.33
|
214
|
+
m = movies(:oz)
|
215
|
+
check_average m, 5
|
216
|
+
m = movies(:crash)
|
217
|
+
check_average m, 0
|
218
|
+
m.rate 3, users(:john)
|
219
|
+
m.rate 5, users(:bill)
|
220
|
+
check_average m, 4
|
221
|
+
m.rate 3, users(:bill)
|
222
|
+
check_average m, 3
|
223
|
+
m.unrate users(:bill)
|
224
|
+
check_average m, 3
|
225
|
+
|
226
|
+
c = cars(:camry)
|
227
|
+
check_average c, 3
|
228
|
+
c = cars(:bug)
|
229
|
+
check_average c, 4
|
230
|
+
c = cars(:expedition)
|
231
|
+
check_average c, 0
|
232
|
+
c.rate 3
|
233
|
+
c.rate 5
|
234
|
+
check_average c, 4
|
235
|
+
c.rate 3
|
236
|
+
check_average c, 3.66
|
237
|
+
end
|
238
|
+
|
239
|
+
def test_count
|
240
|
+
m = movies(:gone_with_the_wind)
|
241
|
+
assert_equal 3, m.rated_count
|
242
|
+
m.rate 4, users(:john)
|
243
|
+
m.rate 4, users(:bill)
|
244
|
+
m.rate 4, users(:sarah)
|
245
|
+
m.rate 4, users(:jane)
|
246
|
+
m.rate 4, users(:jill)
|
247
|
+
assert_equal 5, m.rated_count
|
248
|
+
|
249
|
+
c = cars(:expedition)
|
250
|
+
assert_equal 0, c.rated_count
|
251
|
+
c.rate 4
|
252
|
+
c.rate 4
|
253
|
+
c.rate 4
|
254
|
+
c.rate 4
|
255
|
+
c.rate 4
|
256
|
+
assert_equal 5, c.rated_count
|
257
|
+
|
258
|
+
b = books(:animal_farm)
|
259
|
+
assert_equal 4, b.rated_count
|
260
|
+
b.rate 4, Worker.find(users(:john).id)
|
261
|
+
b.rate 4, Worker.find(users(:bill).id)
|
262
|
+
b.rate 4, Worker.find(users(:sarah).id)
|
263
|
+
b.rate 4, Worker.find(users(:jane).id)
|
264
|
+
b.rate 4, Worker.find(users(:jill).id)
|
265
|
+
assert_equal 5, b.rated_count
|
266
|
+
end
|
267
|
+
|
268
|
+
def test_total
|
269
|
+
m = movies(:gone_with_the_wind)
|
270
|
+
assert_equal 13, m.rated_total
|
271
|
+
m.rate 4, users(:john)
|
272
|
+
m.rate 4, users(:bill)
|
273
|
+
m.rate 4, users(:sarah)
|
274
|
+
m.rate 4, users(:jane)
|
275
|
+
m.rate 4, users(:jill)
|
276
|
+
assert_equal 20, m.rated_total
|
277
|
+
|
278
|
+
c = cars(:expedition)
|
279
|
+
assert_equal 0, c.rated_total
|
280
|
+
c.rate 4
|
281
|
+
c.rate 4
|
282
|
+
c.rate 4
|
283
|
+
c.rate 4
|
284
|
+
c.rate 4
|
285
|
+
assert_equal 20, c.rated_total
|
286
|
+
|
287
|
+
b = books(:animal_farm)
|
288
|
+
assert_equal 12, b.rated_total
|
289
|
+
b.rate 4, Worker.find(users(:john).id)
|
290
|
+
b.rate 4, Worker.find(users(:bill).id)
|
291
|
+
b.rate 4, Worker.find(users(:sarah).id)
|
292
|
+
b.rate 4, Worker.find(users(:jane).id)
|
293
|
+
b.rate 4, Worker.find(users(:jill).id)
|
294
|
+
assert_equal 20, b.rated_total
|
295
|
+
end
|
296
|
+
|
297
|
+
def test_rated_by?
|
298
|
+
m = movies(:gone_with_the_wind)
|
299
|
+
m.rate 4, users(:john)
|
300
|
+
m.rate 4, users(:bill)
|
301
|
+
m.rate 4, users(:sarah)
|
302
|
+
m.rate 4, users(:jane)
|
303
|
+
m.rate 4, users(:jill)
|
304
|
+
m.unrate users(:jill)
|
305
|
+
m.unrate users(:sarah)
|
306
|
+
assert m.rated_by?(users(:john))
|
307
|
+
assert m.rated_by?(users(:bill))
|
308
|
+
assert m.rated_by?(users(:jane))
|
309
|
+
assert !m.rated_by?(users(:jill))
|
310
|
+
assert !m.rated_by?(users(:sarah))
|
311
|
+
|
312
|
+
b = books(:animal_farm)
|
313
|
+
b.rate 4, Worker.find(users(:john).id)
|
314
|
+
b.rate 4, Worker.find(users(:bill).id)
|
315
|
+
b.rate 4, Worker.find(users(:sarah).id)
|
316
|
+
b.rate 4, Worker.find(users(:jane).id)
|
317
|
+
b.rate 4, Worker.find(users(:jill).id)
|
318
|
+
b.unrate Worker.find(users(:john).id)
|
319
|
+
b.unrate Worker.find(users(:bill).id)
|
320
|
+
assert !b.rated_by?(Worker.find(users(:john).id) )
|
321
|
+
assert !b.rated_by?(Worker.find(users(:bill).id) )
|
322
|
+
assert b.rated_by?(Worker.find(users(:sarah).id))
|
323
|
+
assert b.rated_by?(Worker.find(users(:jane).id) )
|
324
|
+
assert b.rated_by?(Worker.find(users(:jill).id) )
|
325
|
+
end
|
326
|
+
|
327
|
+
def test_find_by_rating
|
328
|
+
cs = Car.find_by_rating 0
|
329
|
+
assert_equal 1, cs.size
|
330
|
+
assert_equal 'Ford Expedition', cs[0].title
|
331
|
+
cs = Car.find_by_rating 3
|
332
|
+
assert_equal 1, cs.size
|
333
|
+
assert_equal 'Toyota Camry', cs[0].title
|
334
|
+
cs = Car.find_by_rating 3.5
|
335
|
+
assert_equal 1, cs.size
|
336
|
+
assert_equal 'VW Golf', cs[0].title
|
337
|
+
cs = Car.find_by_rating 4, 0
|
338
|
+
check_returned_array cs, ['VW Golf', 'Carrera', 'VW Bug']
|
339
|
+
cs = Car.find_by_rating 3..4, 0
|
340
|
+
check_returned_array cs, ['Toyota Camry', 'VW Golf', 'Carrera', 'VW Bug']
|
341
|
+
cs = Car.find_by_rating 3..4
|
342
|
+
check_returned_array cs, ['Toyota Camry', 'VW Golf', 'VW Bug']
|
343
|
+
fs = Film.find_by_rating 1..4, 0
|
344
|
+
check_returned_array fs, ["Rambo 3", "Gone With The Wind", "Phantom Menace"]
|
345
|
+
cs = Car.find_by_rating 3..4, 0, false
|
346
|
+
check_returned_array cs, ['Toyota Camry', 'VW Golf', 'VW Bug']
|
347
|
+
cs = Car.find_by_rating 3..4.5, 0, false
|
348
|
+
check_returned_array cs, ['Toyota Camry', 'VW Golf', 'Carrera', 'VW Bug']
|
349
|
+
fs = Film.find_by_rating 1..4, 0, false
|
350
|
+
check_returned_array fs, ["Rambo 3", "Phantom Menace"]
|
351
|
+
ms = Movie.find_by_rating 5
|
352
|
+
check_returned_array ms, ["The Wizard of Oz"]
|
353
|
+
bs = Book.find_by_rating 3..3.7
|
354
|
+
check_returned_array bs, ["Alice in Wonderland", "Aminal Farm", "The Lord of the Rings", "Catch 22"]
|
355
|
+
bs = Book.find_by_rating 3..3.7, 0
|
356
|
+
check_returned_array bs, ["Alice in Wonderland", "Aminal Farm", "The Lord of the Rings"]
|
357
|
+
bs = Book.find_by_rating 1..3, 0
|
358
|
+
check_returned_array bs, ["Alice in Wonderland", "Aminal Farm", "The Lord of the Rings"]
|
359
|
+
bs = Book.find_by_rating 3, 0
|
360
|
+
check_returned_array bs, ["Alice in Wonderland", "Aminal Farm", "The Lord of the Rings"]
|
361
|
+
|
362
|
+
bs = Book.find_by_rating 3..3.7, 0, false
|
363
|
+
check_returned_array bs, ["Alice in Wonderland", "Aminal Farm", "The Lord of the Rings", "Catch 22"]
|
364
|
+
bs = Book.find_by_rating 1..3.3, 0, false
|
365
|
+
check_returned_array bs, ["Alice in Wonderland", "Aminal Farm", "The Lord of the Rings"]
|
366
|
+
bs = Book.find_by_rating 3.75, 0, false
|
367
|
+
check_returned_array bs, ["Shogun"]
|
368
|
+
end
|
369
|
+
|
370
|
+
def test_find_rated_by
|
371
|
+
assert_raise(ActiveRecord::Acts::Rated::RateError) { Car.find_rated_by 5 }
|
372
|
+
assert_raise(ActiveRecord::Acts::Rated::RateError) { Movie.find_rated_by nil }
|
373
|
+
assert_raise(ActiveRecord::Acts::Rated::RateError) { Movie.find_rated_by 1 }
|
374
|
+
ms = Movie.find_rated_by users(:john)
|
375
|
+
check_returned_array ms, ["Gone With The Wind", "The Wizard of Oz", "Phantom Menace", "Rambo 3"]
|
376
|
+
ms = Movie.find_rated_by users(:jack)
|
377
|
+
check_returned_array ms, []
|
378
|
+
m = Movie.new :title => 'Borat'
|
379
|
+
m.save
|
380
|
+
m.rate 5, users(:jack)
|
381
|
+
ms = Movie.find_rated_by users(:jack)
|
382
|
+
check_returned_array ms, ["Borat"]
|
383
|
+
bs = Book.find_rated_by Worker.find(users(:john).id)
|
384
|
+
check_returned_array bs, ["The Lord of the Rings", "Alice in Wonderland", "Catch 22", "Aminal Farm"]
|
385
|
+
fs = Film.find_rated_by users(:john)
|
386
|
+
check_returned_array fs, ["Gone With The Wind", "Phantom Menace", "The Wizard of Oz", "Rambo 3"]
|
387
|
+
f = Film.new :title => 'Kill Bill'
|
388
|
+
f.save
|
389
|
+
f.rate 4, users(:jill)
|
390
|
+
fs = Film.find_rated_by users(:jill)
|
391
|
+
check_returned_array fs, ["Rambo 3", "Phantom Menace", "Kill Bill"]
|
392
|
+
end
|
393
|
+
|
394
|
+
def test_associations
|
395
|
+
assert User.new.respond_to?(:ratings)
|
396
|
+
assert !Mechanic.new.respond_to?(:ratings)
|
397
|
+
assert Book.new.respond_to?(:ratings)
|
398
|
+
assert Book.new.respond_to?(:raters)
|
399
|
+
assert Car.new.respond_to?(:ratings)
|
400
|
+
assert !Car.new.respond_to?(:raters)
|
401
|
+
assert Truck.new.respond_to?(:ratings)
|
402
|
+
assert !Truck.new.respond_to?(:raters)
|
403
|
+
end
|
404
|
+
|
405
|
+
# This just test that the fixtures data makes sense
|
406
|
+
def test_all_fixtures
|
407
|
+
[Car, Movie, Book, Video, Tape, Truck, Film].each do |c|
|
408
|
+
c.find(:all).each do |o|
|
409
|
+
check_average o, o.rating_average
|
410
|
+
end
|
411
|
+
end
|
412
|
+
end
|
413
|
+
|
414
|
+
def check_average obj, value
|
415
|
+
assert_equal (value * 100).to_i, (obj.rating_average * 100).to_i
|
416
|
+
assert_equal (obj.ratings.average(:rating) * 100).to_i, (obj.rating_average * 100).to_i
|
417
|
+
end
|
418
|
+
|
419
|
+
def check_returned_array ar, expected_list
|
420
|
+
names = ar.collect {|e| e.title }
|
421
|
+
assert_equal expected_list.size, names.size
|
422
|
+
assert_equal [], names - expected_list
|
423
|
+
end
|
424
|
+
|
425
|
+
end
|
426
|
+
|
metadata
ADDED
@@ -0,0 +1,60 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: acts-as-rated
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: "0.4"
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Guy Noar
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
|
12
|
+
date: 2009-01-08 00:00:00 +01:00
|
13
|
+
default_executable:
|
14
|
+
dependencies: []
|
15
|
+
|
16
|
+
description: Flexible, configurable, and easy to use with the defaults. Supports 3 different ways to manage rating statistics.
|
17
|
+
email: guy.naor@famundo.com
|
18
|
+
executables: []
|
19
|
+
|
20
|
+
extensions: []
|
21
|
+
|
22
|
+
extra_rdoc_files: []
|
23
|
+
|
24
|
+
files:
|
25
|
+
- acts-as-rated.gemspec
|
26
|
+
- init.rb
|
27
|
+
- lib/acts_as_rated.rb
|
28
|
+
- MIT-LICENSE
|
29
|
+
- Rakefile
|
30
|
+
- README
|
31
|
+
has_rdoc: true
|
32
|
+
homepage: http://github.com/jasherai/acts-as-rated
|
33
|
+
licenses: []
|
34
|
+
|
35
|
+
post_install_message:
|
36
|
+
rdoc_options: []
|
37
|
+
|
38
|
+
require_paths:
|
39
|
+
- lib
|
40
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
41
|
+
requirements:
|
42
|
+
- - ">="
|
43
|
+
- !ruby/object:Gem::Version
|
44
|
+
version: "0"
|
45
|
+
version:
|
46
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
47
|
+
requirements:
|
48
|
+
- - ">="
|
49
|
+
- !ruby/object:Gem::Version
|
50
|
+
version: "0"
|
51
|
+
version:
|
52
|
+
requirements: []
|
53
|
+
|
54
|
+
rubyforge_project:
|
55
|
+
rubygems_version: 1.3.5
|
56
|
+
signing_key:
|
57
|
+
specification_version: 3
|
58
|
+
summary: Rails plugin rating system for ActiveRecord models.
|
59
|
+
test_files:
|
60
|
+
- test/rated_test.rb
|