is_reviewable 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2009 Jonas Grimfelt
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.
@@ -0,0 +1,421 @@
1
+ h1. IS_REVIEWABLE ~ concept version ~
2
+
3
+ _Rails: Make an ActiveRecord resource ratable/reviewable (rating + comment), without the usual extra code-smell._
4
+
5
+ h2. Why another rating-plugin?
6
+
7
+ Many reasons; I felt the existing plugins all suck in one way or another (sorry, no hating). This is why IsReviweable is better if you ask me:
8
+
9
+ * Don't do assumptions that your rater/reviewer model is @User@. Relying on polymorphic assocation completely, so your reviewer can be...@DonaldDuck@.
10
+ * ...and optionally even accepts an IP as rater/reviewer. Disabled by default though.
11
+ * Don't make assumptions about your Review model - you can extend it without meta-programming, good 'ol subclassing folks.
12
+ * Don't make assumptions about what rating scale you wanna have, how the rating scale should be divided, or average rating rounding precision. The 1-5 scale is 80% of the cases, but there's no real reason or overhead to support any scale. To sum it up: Scale can consist negative and/or positive range or explicit integer/float values...and you won't even notice the difference on the outside. See the examples! =)
13
+ * Possible to submit additional custom attributes while rating, such as @title@ and @body@ to make up a "review" instead of just a "rating". Feel free.
14
+ * Very sassy finders implemented using named scopes, i.e. less code smell.
15
+ * No lame out-of-the-box views/javascripts/images that you probably won't use in the final product anyway. That should be an extension to the plugin.
16
+ * Transparently supports column-caching expensive calculations for the reviewable model. Will simply be turned on if these fields exists - otherwise fallback with an optimized DB hit instead.
17
+ * If I can say it myself...this code should be very solid, optimized, and DRY. Shoot the messenger! o<;)
18
+
19
+ If you got suggestions how it could be even better, just hit me up. I appreciate critics for the cause of a rock solid plugin for rating/reviewing. Such a common "problem" as rating/reviewing should have had a great solution by now!
20
+
21
+ h2. Installation
22
+
23
+ *Gem:*
24
+
25
+ <pre>sudo gem install grimen-is_reviewable</pre>
26
+
27
+ and in @config/environment.rb@:
28
+
29
+ <pre>config.gem 'grimen-is_reviewable', :lib => 'is_reviewable'</pre>
30
+
31
+ *Plugin:*
32
+
33
+ <pre>./script/plugin install git://github.com/grimen/is_reviewable.git</pre>
34
+
35
+ h2. Usage
36
+
37
+ h3. 1. Generate migration:
38
+
39
+ <pre>$ ./script/generate is_reviewable_migration</pre>
40
+
41
+ Generates @db/migrations/{timestamp}_is_reviewable_migration@ with:
42
+
43
+ <pre>
44
+ class IsReviewableMigration < ActiveRecord::Migration
45
+ def self.up
46
+ create_table :reviews do |t|
47
+ t.references :reviewable, :polymorphic => true
48
+
49
+ t.references :reviewer, :polymorphic => true
50
+ t.string :ip, :limit => 24
51
+
52
+ t.float :rating
53
+ t.text :body
54
+
55
+ #
56
+ # Custom fields goes here...
57
+ #
58
+ # t.string :title
59
+ # t.string :mood
60
+ # ...
61
+ #
62
+
63
+ t.timestamps
64
+ end
65
+
66
+ add_index :reviews, :reviewer_id
67
+ add_index :reviews, :reviewer_type
68
+ add_index :reviews, [:reviewer_id, :reviewer_type]
69
+ add_index :reviews, [:reviewable_id, :reviewable_type]
70
+ end
71
+
72
+ def self.down
73
+ drop_table :reviews
74
+ end
75
+ end
76
+ </pre>
77
+
78
+ h3. 2. Make your model reviewable:
79
+
80
+ <pre>
81
+ class Post < ActiveRecord::Base
82
+ is_reviewable :scale => 0..5
83
+ end
84
+ </pre>
85
+
86
+ or, with explicit reviewer (or reviewers):
87
+
88
+ <pre>
89
+ class Book < ActiveRecord::Base
90
+ # Setup associations for the reviewer class(es) automatically, and specify an explicit scale instead.
91
+ is_reviewable :by => [:users, :journalists], :scale => 0..5
92
+ end
93
+ </pre>
94
+
95
+ *Note:* @:by@ is optional if you choose any of @User@ or @Account@ as reviewer classes.
96
+
97
+ h3. 3. ...and here we go:
98
+
99
+ Examples:
100
+
101
+ <pre>
102
+ Review.destroy_all # just in case...
103
+ @post = Post.first
104
+ @user = User.first
105
+
106
+ @post.review!(:reviewer => @user, :rating => 2) # new reviewer (type: object) => create
107
+ @post.review!(:reviewer => @user, :rating => 5) # same reviewer (type: object) => update
108
+
109
+ @post.total_reviews # => 1
110
+ @post.average_rating # => 5.0
111
+
112
+ @post.review!(:reviewer => '128.0.0.0', :rating => 4) # new reviewer (type: IP) => create
113
+ @post.review!(:reviewer => '128.0.0.0', :rating => 2) # same reviewer (type: IP) => update
114
+
115
+ @post.total_reviews # => 2
116
+ @post.average_rating # => 3.5
117
+
118
+ @post.review!(:reviewer => @user, :rating => nil, :body => "Lorem ipsum...") # same reviewer (type: IP) => update
119
+
120
+ @post.total_reviews # => 2
121
+ @post.average_rating # => 2.0, i.e. don't count nil (a.k.a. "no opinion")
122
+
123
+ @post.unreview!(:reviewer => '128.0.0.0') # delete existing review (type: IP) => destroy
124
+
125
+ @post.total_reviews # => 1
126
+ @post.average_rating # => 2.0
127
+
128
+ @post.reviews # => reviews on @post
129
+ @post.reviewers # => reviewers that reviewed @post
130
+ @user.reviews # => reviews by @user
131
+ @user.reviewables # => reviewable objects that got reviewed by @user
132
+
133
+ # TODO: A few more samples...
134
+
135
+ # etc...
136
+ </pre>
137
+
138
+ h2. Mixin Arguments
139
+
140
+ The @is_reviewable@ mixin takes some hash arguments for customization:
141
+
142
+ *Basic*
143
+
144
+ * @:by@ - the reviewer model, e.g. User, Account, etc. (accepts either symbol or class, i.e. @User@ <=> @:user@ <=> @:users@). The reviewer model will be setup for you. Note: Polymorhic, so it accepts any model. Default: @User@, or auto-detection fails.
145
+ * @:scale@/@:range@/@:values@ - range, or array, of valid rating values. Default: @1..5@. Note: Negative values are allowed too, and a range of values are not required, i.e. [-1, 1] is valid as well as [1,3,5]. =)
146
+ * @:accept_ip@ - accept anonymous users uniquely identified by IP (well...you handle the bots =D). See examples below how to use this in comparison to reviewer model. Default: @false@.
147
+
148
+ *Advanced*
149
+
150
+ * @:total_precision@ - maximum number of digits for the average rating value. Default: @1@.
151
+ * @:step@ - useful if you want to specify a custom step for each scale value within a range of values. Default: @1@ for range of fixnum, auto-detected based on first value in range of float.
152
+ * @:steps@ - similar to @:step@ (they relate to each other), but instead of specifying a step you can specify how many steps you want. Default: auto-detected based on custom or default value @:step@.
153
+
154
+ h2. Finders
155
+
156
+ IsReviewable has plenty of useful finders implemented using named scopes. Here they are:
157
+
158
+ h3. @Review@
159
+
160
+ *Order:*
161
+
162
+ * @in_order@ - most recent reviews last (order by creation date).
163
+ * @most_recent@ - most recent reviews first (opposite of @in_order@ above).
164
+ * @lowest_rating@ - reviews with lowest ratings first.
165
+ * @highest_rating@ - reviews with lowest ratings first.
166
+
167
+ *Filter:*
168
+
169
+ * @limit(<number_of_items>)@ - maximum @<number_of_items>@ reviews.
170
+ * @since(<created_at_datetime>)@ - reviews created since @<created_at_datetime>@.
171
+ * @recent(<datetime_or_size>)@ - if DateTime: reviews created since @<datetime_or_size>@, else if Fixnum: pick last @<datetime_or_size>@ number of reviews.
172
+ * @between_dates(<from_date>, to_date)@ - reviews created between two datetimes.
173
+ * @with_rating(<rating_value_or_range>)@ - reviews with(in) rating value (or range) @<rating_value_or_range>@.
174
+ * @with_a_rating@ - reviews with a rating value, i.e. not nil.
175
+ * @without_a_rating@ - opposite of @with_a_rating@ (above).
176
+ * @with_a_body@ - reviews with a body/comment, i.e. not nil/blank.
177
+ * @without_a_body@ - opposite of @with_a_body@ (above).
178
+ * @complete@ - reviews with both rating and comments, i.e. "full reviews" where.
179
+ * @of_reviewable_type(<reviewable_type>)@ - reviews of @<reviewable_type>@ type of reviewable models.
180
+ * @by_reviewer_type(<reviewer_type>)@ - reviews of @<reviewer_type>@ type of reviewer models.
181
+ * @on(<reviewable_object>)@ - reviews on the reviewable object @<reviewable_object>@ .
182
+ * @by(<reviewer_object>)@ - reviews by the @<reviewer_object>@ type of reviewer models.
183
+
184
+ h3. @Reviewable@
185
+
186
+ *Order:*
187
+
188
+ _TODO: Documentation on named scopes for reviewable._
189
+
190
+ *Filter:*
191
+
192
+ _TODO: Documentation on named scopes for reviewable._
193
+
194
+ h3. @Reviewer@
195
+
196
+ *Order:*
197
+
198
+ _TODO: Documentation on named scopes for reviewable._
199
+
200
+ *Filter:*
201
+
202
+ _TODO: Documentation on named scopes for reviewable._
203
+
204
+ h3. Examples using finders:
205
+
206
+ <pre>
207
+ @user = User.first
208
+ @post = Post.first
209
+
210
+ @post.reviews.recent(10) # => [10 most recent reviews]
211
+ @post.reviews.recent(1.week.ago) # => [reviews created since 1 week ago]
212
+
213
+ @post.reviews.with_rating(3.5..4.0) # => [all reviews on @post with rating between 3.5 and 4.0]
214
+
215
+ @post.reviews.by_reviewer_type(:user) # => [all reviews on @post by User-objects]
216
+ # ...or:
217
+ @post.reviews.by_reviewer_type(:users) # => [all reviews on @post by User-objects]
218
+ # ...or:
219
+ @post.reviews.by_reviewer_type(User) # => [all reviews on @post by User-objects]
220
+
221
+ @user.reviews.on(@post) # => [all reviews by @user on @post]
222
+ @post.reviews.by(@user) # => [all reviews by @user on @post] (equivalent with above)
223
+
224
+ Review.on(@post) # => [all reviews on @user] <=> @post.reviews
225
+ Review.by(@user) # => [all reviews by @user] <=> @user.reviews
226
+
227
+ # etc, etc. It's all named scopes, so it's really no new hokus-pokus you have to learn.
228
+ </pre>
229
+
230
+ h2. Additional Methods
231
+
232
+ *Note:* See documentation (RDoc).
233
+
234
+ h2. Extend the Review model
235
+
236
+ This is optional, but if you wanna be in control of your models (in this case @Review@) you can take control like this:
237
+
238
+ <pre>
239
+ class Review < IsReviewable::Review
240
+
241
+ # Do what you do best here... (stating the obvious: core IsReviewable associations, named scopes, etc. will be inherited)
242
+
243
+ end
244
+ </pre>
245
+
246
+ h2. Caching
247
+
248
+ If the visitable class table - in the sample above @Post@ - contains a columns @reviews_count@ and @average_rating@, then a cached value will be maintained within it for the number of reviews and the average rating the object have got.
249
+
250
+ Additional caching fields (to a reviewable model table):
251
+
252
+ <pre>
253
+ class AddIsReviewableToPostsMigration < ActiveRecord::Migration
254
+ def self.up
255
+ # Enable is_reviewable-caching.
256
+ add_column :posts, :cached_total_reviews, :integer
257
+ add_column :posts, :cached_average_rating, :integer
258
+ end
259
+
260
+ def self.down
261
+ remove_column :posts, :cached_total_reviews
262
+ remove_column :posts, :cached_average_rating
263
+ end
264
+ end
265
+ </pre>
266
+
267
+ h2. Example
268
+
269
+ h3. Controller
270
+
271
+ Depending on your implementation: You might - or might not - need a Controller, but for most cases where you only want to allow rating of something, a controller most probably is overkill. In the case of a review, this is how one cold look like (in this example, I'm using the excellent the "InheritedResources":http://github.com/josevalim/inherited_resources):
272
+
273
+ Example: @app/controllers/reviews_controller.rb@:
274
+
275
+ <pre>
276
+ class ReviewsController < InheritedResources::Base
277
+
278
+ actions :create, :update, :destroy
279
+ respond_to :js
280
+ layout false
281
+
282
+ end
283
+ </pre>
284
+
285
+ ..or in the more basic rating case - @app/controllers/posts_controller.rb@:
286
+
287
+ <pre>
288
+ class PostsController < InheritedResources::Base
289
+
290
+ actions :all
291
+ respond_to :html, :js
292
+ layout false if request.format == :js
293
+
294
+ def rate
295
+ begin
296
+ @post.review! :reviewer => current_user, params.slice(:rating, :body)
297
+ rescue
298
+ flash[:error] = 'Sad panda...could not rate for some reason. O_o'
299
+ end
300
+ respond_to do |format|
301
+ format.html { redirect_to @post }
302
+ format.js # app/views/posts/rate.js.rjs
303
+ end
304
+ end
305
+
306
+ end
307
+ </pre>
308
+
309
+ h3. Routes
310
+
311
+ @config/routes.rb@
312
+
313
+ <pre>
314
+ map.resources :posts, :member => {:rate => :put}
315
+ </pre>
316
+
317
+ h3. Views
318
+
319
+ IsReviewable comes with no view templates (etc.) because of already stated reasons, but basic rating mechanism is trivial to implement (in this example, I'm using HAML because I despise ERB):
320
+
321
+ Example: @app/views/posts/show.html.haml@
322
+
323
+ <pre>
324
+ %h1
325
+ = @post.title
326
+ %p
327
+ = @post.body
328
+ %p
329
+ = "Your rating:"
330
+ #rating_wrapper= render '/reviews/rating', :resource => @post
331
+ </pre>
332
+
333
+ Example: @app/views/reviews/_rating.html.haml@
334
+
335
+ <pre>
336
+ .rating
337
+ - if resource.present? && resource.reviewable?
338
+ - if reviewer.present?
339
+ - current_rating = resource.review_by(reviewer).try(:rating)
340
+ - resource.reviewable_scale.each do |rating|
341
+ = link_to_remote "#{rating.to_i}", :url => rate_post_path(resource, :rating => rating.to_i), :method => :put, :html => {:class => "rate rated_#{rating.to_i}#{' current' if current_rating == rating}"}
342
+ = link_to_remote "no opinion", :url => rate_post_path(resource, :rating => nil), :method => :put, :html => {:class => "rate rated_none#{' current' unless current_rating}"}
343
+ - else # static rating
344
+ - current_rating = resource.average_rating.round
345
+ - resource.reviewable_scale.each do |rating|
346
+ {:class => "rate rated_#{rating}#{' current' if current_rating == rating}"}
347
+ </pre>
348
+
349
+ *Note:* Skipping review-body (etc.) in this view sample, but that is straightforward to implement anyways.
350
+
351
+ h3. JavaScript/AJAX
352
+
353
+ ...and finally - as we in this example using AJAX - the javascript response (in this example, I'm using RJS + jQuery - don't get me started on why I don't use Prototype for UI...).
354
+
355
+ Example: @app/views/reviews/rate.js.rjs@
356
+
357
+ <pre>
358
+ page.replace_html '#rating_wrapper', render('/reviews/rating', :reviewable => @post, :reviewer => current_user)
359
+ page.visual_effect :highlight, '.rating .current'
360
+ </pre>
361
+
362
+ Done! =)
363
+
364
+ h2. Design Implications: Additional Use Cases
365
+
366
+ h3. Like/Dislike
367
+
368
+ IsReviewable is designed in such way that you as a developer are not locked to how traditional rating works. As an example, this is how you could implement like/dislike (like VoteFu) pattern using IsReviewable:
369
+
370
+ Example:
371
+
372
+ <pre>
373
+ class Post < ActiveRecord::Base
374
+ is_reviewable :by => :users, :values => [-1, 1]
375
+ end
376
+ </pre>
377
+
378
+ *Note:* @:values@ is an alias for @:scale@ for semantical reasons in cases like these.
379
+
380
+ h2. Dependencies
381
+
382
+ Basic usage:
383
+
384
+ * "rails":http://github.com/rails/rails (well...)
385
+
386
+ For running tests:
387
+
388
+ * sqlite3-ruby
389
+ * "thoughtbot-shoulda":http://github.com/thoughtbot/shoulda
390
+ * "nakajima-acts_as_fu":http://github.com/nakajima/acts_as_fu
391
+ * "jgre-monkeyspecdoc":http://github.com/jgre/monkeyspecdoc (optional)
392
+
393
+ h2. Notes
394
+
395
+ * Tested with Ruby 1.9.1.
396
+ * Let me know if you find any bugs; not used in production yet so consider this a concept version.
397
+
398
+ h2. TODO
399
+
400
+ h3. Priority:
401
+
402
+ * bug: Accept multiple reviewers (of different types): @average_rating_by@, etc..
403
+ * documentation: A few more README-examples.
404
+ * feature: Reviewable on multiple contexts, e.g. @is_reviewable :on => [:acting, :writing, ...]@. Alias method @is_reviewable_on@.
405
+ * testing: More thorough tests, especially for named scopes which is a bit tricky.
406
+ * feature: Useful finders for @Reviewable@.
407
+ * feature: Useful finders for @Reviewer@.
408
+
409
+ h3. Maybe:
410
+
411
+ * feature: Make alias helpers to implement functionality like VoteFu (http://github.com/peteonrails/vote_fu), simply just aliasing methods with existing ones with hardcoded parameters. Not required because this is supported already, it's all about semantics and sassy code.
412
+
413
+ h2. Related Links
414
+
415
+ ...that might be of interest.
416
+
417
+ * "jQuery Star Rating":http://github.com/rathgar/jquery-star-rating/ - javascript star rating plugin for Rails on jQuery, if you don't want to do the rating widget on your own. It should be quite straightforward to customize the appearance of it for your needs too.
418
+
419
+ h2. License
420
+
421
+ Copyright (c) 2009 Jonas Grimfelt, released under the MIT-license.
@@ -0,0 +1,51 @@
1
+ # coding: utf-8
2
+ require 'rubygems'
3
+ require 'rake'
4
+ require 'rake/testtask'
5
+ require 'rake/rdoctask'
6
+
7
+ NAME = "is_reviewable"
8
+ SUMMARY = %Q{Rails: Make an ActiveRecord resource ratable/reviewable (rate + text), without the usual extra code-smell.}
9
+ HOMEPAGE = "http://github.com/grimen/#{NAME}"
10
+ AUTHOR = "Jonas Grimfelt"
11
+ EMAIL = "grimen@gmail.com"
12
+ SUPPORT_FILES = %w(README.textile)
13
+
14
+ begin
15
+ gem 'technicalpickles-jeweler', '>= 1.2.1'
16
+ require 'jeweler'
17
+ Jeweler::Tasks.new do |gem|
18
+ gem.name = NAME
19
+ gem.summary = SUMMARY
20
+ gem.description = SUMMARY
21
+ gem.homepage = HOMEPAGE
22
+ gem.author = AUTHOR
23
+ gem.email = EMAIL
24
+
25
+ gem.require_paths = %w{lib}
26
+ gem.files = SUPPORT_FILES << %w(MIT-LICENSE Rakefile) << Dir.glob(File.join('{generators,lib,test,rails}', '**', '*'))
27
+ gem.executables = %w()
28
+ gem.extra_rdoc_files = SUPPORT_FILES
29
+ end
30
+ rescue LoadError
31
+ puts "Jeweler, or one of its dependencies, is not available. Install it with: sudo gem install technicalpickles-jeweler -s http://gems.github.com"
32
+ end
33
+
34
+ desc %Q{Default: Run unit tests for "#{NAME}".}
35
+ task :default => :test
36
+
37
+ desc %Q{Run unit tests for "#{NAME}".}
38
+ Rake::TestTask.new(:test) do |test|
39
+ test.libs << %w(lib test)
40
+ test.pattern = File.join('test', '**', '*_test.rb')
41
+ test.verbose = true
42
+ end
43
+
44
+ desc %Q{Generate documentation for "#{NAME}".}
45
+ Rake::RDocTask.new(:rdoc) do |rdoc|
46
+ rdoc.rdoc_dir = 'rdoc'
47
+ rdoc.title = NAME
48
+ rdoc.options << '--line-numbers' << '--inline-source' << '--charset=UTF-8'
49
+ rdoc.rdoc_files.include(SUPPORT_FILES)
50
+ rdoc.rdoc_files.include(File.join('lib', '**', '*.rb'))
51
+ end