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,12 @@
1
+ # coding: utf-8
2
+
3
+ class IsReviewableMigrationGenerator < Rails::Generator::Base
4
+
5
+ def manifest
6
+ record do |m|
7
+ m.migration_template 'reviews_migration.rb',
8
+ File.join('db', 'migrate'), :migration_file_name => 'is_reviewable_migration'
9
+ end
10
+ end
11
+
12
+ end
@@ -0,0 +1,34 @@
1
+ # coding: utf-8
2
+
3
+ class IsReviewableMigration < ActiveRecord::Migration
4
+ def self.up
5
+ create_table :reviews do |t|
6
+ t.references :reviewable, :polymorphic => true
7
+
8
+ t.references :reviewer, :polymorphic => true
9
+ t.string :ip, :limit => 24
10
+
11
+ t.float :rating
12
+ t.text :body
13
+
14
+ #
15
+ # Custom fields goes here...
16
+ #
17
+ # t.string :title
18
+ # t.string :mood
19
+ # ...
20
+ #
21
+
22
+ t.timestamps
23
+ end
24
+
25
+ add_index :reviews, :reviewer_id
26
+ add_index :reviews, :reviewer_type
27
+ add_index :reviews, [:reviewer_id, :reviewer_type]
28
+ add_index :reviews, [:reviewable_id, :reviewable_type]
29
+ end
30
+
31
+ def self.down
32
+ drop_table :reviews
33
+ end
34
+ end
@@ -0,0 +1,11 @@
1
+ # coding: utf-8
2
+
3
+ class IsReviewableModelGenerator < Rails::Generator::Base
4
+
5
+ def manifest
6
+ record do |m|
7
+ m.template 'review_model.rb', File.join('app', 'models', 'review.rb')
8
+ end
9
+ end
10
+
11
+ end
@@ -0,0 +1,5 @@
1
+ # coding: utf-8
2
+
3
+ class Review < ::IsReviewable::Review
4
+
5
+ end
@@ -0,0 +1,34 @@
1
+ # coding: utf-8
2
+ require File.join(File.dirname(__FILE__), *%w[is_reviewable review])
3
+ require File.join(File.dirname(__FILE__), *%w[is_reviewable reviewable])
4
+ require File.join(File.dirname(__FILE__), *%w[is_reviewable reviewer])
5
+ require File.join(File.dirname(__FILE__), *%w[is_reviewable support])
6
+
7
+ module IsReviewable
8
+
9
+ extend self
10
+
11
+ class IsReviewableError < ::StandardError
12
+ def initialize(message)
13
+ ::IsReviewable.log message, :debug
14
+ super message
15
+ end
16
+ end
17
+
18
+ InvalidConfigValueError = ::Class.new(IsReviewableError)
19
+ InvalidReviewerError = ::Class.new(IsReviewableError)
20
+ InvalidReviewValueError = ::Class.new(IsReviewableError)
21
+ RecordError = ::Class.new(IsReviewableError)
22
+
23
+ mattr_accessor :verbose
24
+
25
+ @@verbose = ::Object.const_defined?(:RAILS_ENV) ? (::RAILS_ENV.to_sym == :development) : true
26
+
27
+ def log(message, level = :info)
28
+ return unless @@verbose
29
+ level = :info if level.blank?
30
+ @@logger ||= ::Logger.new(::STDOUT)
31
+ @@logger.send(level.to_sym, message)
32
+ end
33
+
34
+ end
@@ -0,0 +1,38 @@
1
+ # coding: utf-8
2
+
3
+ module IsReviewable
4
+ class Review < ::ActiveRecord::Base
5
+
6
+ belongs_to :reviewable, :polymorphic => true
7
+ belongs_to :reviewer, :polymorphic => true
8
+
9
+ # Order.
10
+ named_scope :in_order, :order => 'created_at ASC'
11
+ named_scope :most_recent, :order => 'created_at DESC'
12
+ named_scope :lowest_rating, :order => 'rating ASC'
13
+ named_scope :highest_rating, :order => 'rating DESC'
14
+
15
+ # Filters.
16
+ named_scope :limit, lambda { |number_of_items| {:limit => number_of_items} }
17
+ named_scope :since, lambda { |created_at_datetime| {:conditions => ['created_at >= ?', created_at_datetime]} }
18
+ named_scope :recent, lambda { |arg|
19
+ if [::ActiveSupport::TimeWithZone, ::DateTime].any? { |c| c.is_a?(arg) }
20
+ {:conditions => ['created_at >= ?', arg]}
21
+ else
22
+ {:limit => arg.to_i}
23
+ end
24
+ }
25
+ named_scope :between_dates, lambda { |from_date, to_date| {:conditions => {:created_at => (from_date..to_date)}} }
26
+ named_scope :with_rating, lambda { |rating_value_or_range| {:conditions => {:rating => rating_value_or_range}} }
27
+ named_scope :with_a_rating, :conditions => ['rating IS NOT NULL']
28
+ named_scope :without_a_rating, :conditions => ['rating IS NULL']
29
+ named_scope :with_a_body, :conditions => ['body IS NOT NULL AND LENGTH(body) > 0']
30
+ named_scope :without_a_body, :conditions => ['body IS NULL OR LENGTH(body) = 0']
31
+ named_scope :complete, :conditions => ['rating IS NOT NULL AND body IS NOT NULL AND LENGTH(body) > 0']
32
+ named_scope :of_reviewable_type, lambda { |type| {:conditions => Support.polymorphic_conditions_for(type, :reviewable, :type)} }
33
+ named_scope :by_reviewer_type, lambda { |type| {:conditions => Support.polymorphic_conditions_for(type, :reviewer, :type)} }
34
+ named_scope :on, lambda { |reviewable| {:conditions => Support.polymorphic_conditions_for(reviewable, :reviewable)} }
35
+ named_scope :by, lambda { |reviewer| {:conditions => Support.polymorphic_conditions_for(reviewer, :reviewer)} }
36
+
37
+ end
38
+ end
@@ -0,0 +1,430 @@
1
+ # coding: utf-8
2
+
3
+ unless defined?(::Review)
4
+ class Review < ::IsReviewable::Review
5
+ end
6
+ end
7
+
8
+ module IsReviewable
9
+ module Reviewable
10
+
11
+ REVIEW_CLASS = ::Review
12
+ DEFAULT_SCALE = 1..5
13
+ DEFAULT_ACCEPT_IP = false
14
+ CACHABLE_FIELDS = [
15
+ :reviews_count,
16
+ :average_rating
17
+ ].freeze
18
+ ASSOCIATION_FIELDS = [
19
+ :reviewable_id,
20
+ :reviewable_type,
21
+ :reviewer_id,
22
+ :reviewer_type,
23
+ :ip
24
+ ].freeze
25
+ CONTENT_FIELDS = [
26
+ :rating,
27
+ :body
28
+ ].freeze
29
+
30
+ def self.included(base) #:nodoc:
31
+ base.class_eval do
32
+ extend ClassMethods
33
+ end
34
+
35
+ # Checks if this object reviewable or not.
36
+ #
37
+ def reviewable?; false; end
38
+ alias :is_reviewable? :reviewable?
39
+ end
40
+
41
+ module ClassMethods
42
+
43
+ # TODO: Document this method...thoroughly.
44
+ #
45
+ # Examples:
46
+ #
47
+ # is_reviewable :by => :user, :scale => 0..5, :total_precision => 2
48
+ #
49
+ def is_reviewable(*args)
50
+ options = args.extract_options!
51
+ options.reverse_merge!(
52
+ :by => Reviewer::DEFAULT_CLASS_NAME,
53
+ :scale => options[:values] || options[:range] || DEFAULT_SCALE,
54
+ :accept_ip => options[:anonymous] || DEFAULT_ACCEPT_IP # i.e. also accepts unique IPs as reviewer
55
+ )
56
+ scale = options[:scale]
57
+ if options[:step].blank? && options[:steps].blank?
58
+ options[:steps] = scale.last - scale.first + 1
59
+ else
60
+ # use :step or :steps beneath
61
+ end
62
+ options[:total_precision] ||= options[:average_precision] || scale.first.to_s.split('.').last.size # == 1
63
+
64
+ # Check for incorrect input values, and handle ranges of floats with help of :step. E.g. :scale => 1.0..5.0.
65
+
66
+ if scale.is_a?(::Range) && scale.first.is_a?(::Float)
67
+ options[:step] = (scale.last - scale.first) / (options[:steps] - 1) if options[:step].blank?
68
+ options[:scale] = scale.first.step(scale.last, options[:step]).collect { |value| value }
69
+ else
70
+ options[:scale] = scale.to_a.collect! { |v| v.to_f }
71
+ end
72
+ raise InvalidConfigValueError, ":scale/:range/:values must consist of numeric values only." unless options[:scale].all? { |v| v.is_a?(::Numeric) }
73
+ raise InvalidConfigValueError, ":total_precision must be an integer." unless options[:total_precision].is_a?(::Fixnum)
74
+
75
+ # Assocations: Review class (e.g. Review).
76
+ options[:review_class] = REVIEW_CLASS
77
+
78
+ # Reviewer class(es).
79
+ options[:by] = [options[:by]] unless options[:by].is_a?(::Array)
80
+ options[:reviewer_class_names] = options[:by].collect { |class_name| class_name.to_s.singularize.classify }
81
+ options[:reviewer_classes] = options[:reviewer_class_names].collect do |class_name|
82
+ begin
83
+ class_name.constantize
84
+ rescue NameError => e
85
+ raise InvalidReviewerError, "Reviewer class #{options[:reviewer_class_name]} not defined, needs to be defined. #{e}"
86
+ end
87
+ end
88
+
89
+ # Had to do this here - not sure why. Subclassing Review be enough? =S
90
+ ::Review.class_eval do
91
+ belongs_to :reviewable, :polymorphic => true unless self.respond_to?(:reviewable)
92
+ belongs_to :reviewer, :polymorphic => true unless self.respond_to?(:reviewer)
93
+ end
94
+
95
+ # Assocations: Reviewer class(es) (e.g. User, Account, ...).
96
+ options[:reviewer_classes].each do |reviewer_class|
97
+ if ::Object.const_defined?(reviewer_class.name.to_sym)
98
+ reviewer_class.class_eval do
99
+ has_many :reviews, :as => :reviewer, :dependent => :delete_all
100
+
101
+ # Polymorphic has-many-through not supported (has_many :reviewables, :through => :reviews), so:
102
+ def reviewables(*args)
103
+ query_options = args.extract_options!
104
+ query_options[:include] = [:reviewable]
105
+ query_options.reverse_merge!(
106
+ :conditions => Support.polymorphic_conditions_for(self, :reviewer)
107
+ )
108
+
109
+ ::Review.find(:all, query_options).collect! { |review| review.reviewable }
110
+ end
111
+ end
112
+ end
113
+ end
114
+
115
+ # Assocations: Reviewable class (self) (e.g. Page).
116
+ self.class_eval do
117
+ has_many :reviews, :as => :reviewable, :dependent => :delete_all
118
+
119
+ # Polymorphic has-many-through not supported (has_many :reviewers, :through => :reviews), so:
120
+ def reviewers(*args)
121
+ query_options = args.extract_options!
122
+ query_options[:include] = [:reviewer]
123
+ query_options.reverse_merge!(
124
+ :conditions => Support.polymorphic_conditions_for(self, :reviewable)
125
+ )
126
+
127
+ ::Review.find(:all, query_options).collect! { |review| review.reviewer }
128
+ end
129
+
130
+ before_create :init_reviewable_caching_fields
131
+
132
+ include ::IsReviewable::Reviewable::InstanceMethods
133
+ extend ::IsReviewable::Reviewable::Finders
134
+ end
135
+
136
+ # Save the initialized options for this class.
137
+ write_inheritable_attribute :is_reviewable_options, options
138
+ class_inheritable_reader :is_reviewable_options
139
+ end
140
+
141
+ # Checks if this object reviewable or not.
142
+ #
143
+ def reviewable?
144
+ @@reviewable ||= self.respond_to?(:is_reviewable_options, true)
145
+ end
146
+ alias :is_reviewable? :reviewable?
147
+
148
+ # The rating scale used for this reviewable class.
149
+ #
150
+ def reviewable_scale
151
+ self.is_reviewable_options[:scale]
152
+ end
153
+ alias :rating_scale :reviewable_scale
154
+
155
+ # The rating value precision used for this reviewable class.
156
+ #
157
+ # Using Rails default behaviour:
158
+ #
159
+ # Float#round(<precision>)
160
+ #
161
+ def reviewable_precision
162
+ self.is_reviewable_options[:total_precision]
163
+ end
164
+ alias :rating_precision :reviewable_precision
165
+
166
+ protected
167
+
168
+ # Check if the requested reviewer object is a valid reviewer.
169
+ #
170
+ def validate_reviewer(identifiers)
171
+ raise InvalidReviewerError, "Argument can't be nil: no reviewer object or IP provided." if identifiers.blank?
172
+ reviewer = identifiers[:reviewer] || identifiers[:user] || identifiers[:account] || identifiers[:ip]
173
+ is_ip = Support.is_ip?(reviewer)
174
+ reviewer = reviewer.to_s.strip if is_ip
175
+ unless Support.is_active_record?(reviewer) || is_ip
176
+ raise InvalidReviewerError, "Reviewer is of wrong type: #{reviewer.inspect}."
177
+ end
178
+ raise InvalidReviewerError, "Reviewing based on IP is disabled." if is_ip && !self.is_reviewable_options[:accept_ip]
179
+ reviewer
180
+ end
181
+
182
+ end
183
+
184
+ module InstanceMethods
185
+
186
+ # Checks if this object reviewable or not.
187
+ #
188
+ def reviewable?
189
+ self.class.reviewable?
190
+ end
191
+ alias :is_reviewable? :reviewable?
192
+
193
+ # The rating scale used for this reviewable class.
194
+ #
195
+ def reviewable_scale
196
+ self.class.reviewable_scale
197
+ end
198
+ alias :rating_scale :reviewable_scale
199
+
200
+ # The rating value precision used for this reviewable class.
201
+ #
202
+ def reviewable_precision
203
+ self.class.reviewable_precision
204
+ end
205
+ alias :rating_precision :reviewable_precision
206
+
207
+ # Reviewed at datetime.
208
+ #
209
+ def reviewed_at
210
+ self.created_at if self.respond_to?(:created_at)
211
+ end
212
+
213
+ # Calculate average rating for this reviewable object.
214
+ #
215
+ def average_rating(recalculate = false)
216
+ if !recalculate && self.reviewable_caching_fields?(:average_rating)
217
+ self.average_rating
218
+ else
219
+ conditions = self.reviewable_conditions(true)
220
+ conditions[0] << ' AND rating IS NOT NULL'
221
+ ::Review.average(:rating,
222
+ :conditions => conditions).to_f.round(self.is_reviewable_options[:total_precision])
223
+ end
224
+ end
225
+
226
+ # Calculate average rating for this reviewable object within a domain of reviewers.
227
+ #
228
+ def average_rating_by(identifiers)
229
+ # FIXME: Only count non-nil ratings, i.e. See "average_rating".
230
+ ::Review.average(:rating,
231
+ :conditions => self.reviewer_conditions(identifiers).merge(self.reviewable_conditions)
232
+ ).to_f.round(self.is_reviewable_options[:total_precision])
233
+ end
234
+
235
+ # Get the total number of reviews for this object.
236
+ #
237
+ def total_reviews(recalculate = false)
238
+ if !recalculate && self.reviewable_caching_fields?(:total_reviews)
239
+ self.total_reviews
240
+ else
241
+ ::Review.count(:conditions => self.reviewable_conditions)
242
+ end
243
+ end
244
+ alias :number_of_reviews :total_reviews
245
+
246
+ # Is this object reviewed by anyone?
247
+ #
248
+ def reviewed?
249
+ self.total_reviews > 0
250
+ end
251
+ alias :is_reviewed? :reviewed?
252
+
253
+ # Check if an item was already reviewed by the given reviewer or ip.
254
+ #
255
+ # === identifiers hash:
256
+ # * <tt>:ip</tt> - identify with IP
257
+ # * <tt>:reviewer/:user/:account</tt> - identify with a reviewer-model (e.g. User, ...)
258
+ #
259
+ def reviewed_by?(identifiers)
260
+ self.reviews.exists?(:conditions => reviewer_conditions(identifiers))
261
+ end
262
+ alias :is_reviewed_by? :reviewed_by?
263
+
264
+ # Get review already reviewed by the given reviewer or ip.
265
+ #
266
+ def review_by(identifiers)
267
+ self.reviews.find(:first, :conditions => reviewer_conditions(identifiers))
268
+ end
269
+
270
+ # View the object with and identifier (user or ip) - create new if new reviewer.
271
+ #
272
+ # === identifiers_and_options hash:
273
+ # * <tt>:reviewer/:user/:account</tt> - identify with a reviewer-model or IP (e.g. User, Account, ..., "128.0.0.1")
274
+ # * <tt>:rating</tt> - Review rating value, e.g. 3.5, "3.5", ... (optional)
275
+ # * <tt>:body</tt> - Review text body, e.g. "Lorem *ipsum*..." (optional)
276
+ # * <tt>:*</tt> - Any custom review field, e.g. :reviewer_mood => "angry" (optional)
277
+ #
278
+ def review!(identifiers_and_options)
279
+ begin
280
+ reviewer = self.validate_reviewer(identifiers_and_options)
281
+ review = self.review_by(identifiers_and_options)
282
+
283
+ # Except for the reserved fields, any Review-fields should be be able to update.
284
+ review_values = identifiers_and_options.except(*ASSOCIATION_FIELDS)
285
+ review_values[:rating] = review_values[:rating].to_f if review_values[:rating].present?
286
+
287
+ if review_values[:rating].present? && !self.valid_rating_value?(review_values[:rating])
288
+ raise InvalidReviewValueError, "Invalid rating value: #{review_values[:rating]} not in [#{self.rating_scale.join(', ')}]."
289
+ end
290
+
291
+ unless review.present?
292
+ # An un-existing reviewer of this reviewable object => Create a new review.
293
+ review = ::Review.new do |r|
294
+ r.reviewable_id = self.id
295
+ r.reviewable_type = self.class.name
296
+
297
+ if Support.is_active_record?(reviewer)
298
+ r.reviewer_id = reviewer.id
299
+ r.reviewer_type = reviewer.class.name
300
+ else
301
+ r.ip = reviewer
302
+ end
303
+ end
304
+ self.reviews << review
305
+ else
306
+ # An existing reviewer of this reviewable object => Update the existing review.
307
+ end
308
+
309
+ # Update non-association attributes, such as rating, body (the review text), and any custom fields.
310
+ review.attributes = review_values.slice(*review.attribute_names.collect { |an| an.to_sym })
311
+
312
+ if self.reviewable_caching_fields?(:total_reviews)
313
+ begin
314
+ self.cached_total_reviews += 1 if review.new_record?
315
+ rescue
316
+ self.cached_total_reviews = self.total_reviews(true)
317
+ end
318
+ end
319
+
320
+ if self.reviewable_caching_fields?(:average_rating)
321
+ self.cached_average_rating = self.average_rating(true)
322
+ # new_rating = review.rating - (old_rating || 0)
323
+ # self.cached_average_rating = (self.cached_average_rating + new_rating) / self.cached_total_reviews.to_f
324
+ end
325
+
326
+ review.save && self.save_without_validation
327
+ review
328
+ rescue InvalidReviewerError, InvalidReviewValueError => e
329
+ raise e
330
+ rescue Exception => e
331
+ raise RecordError, "Could not create/update review #{review.inspect} by #{reviewer.inspect}: #{e}"
332
+ end
333
+ end
334
+
335
+ # Remove the review of this reviewer from this object.
336
+ #
337
+ def unreview!(identifiers)
338
+ review = self.review_by(identifiers)
339
+ review_rating = review.rating if review.present?
340
+
341
+ if review && review.destroy
342
+ if self.reviewable_caching_fields?(:total_reviews)
343
+ begin
344
+ self.cached_total_reviews -= 1
345
+ rescue
346
+ self.cached_total_reviews = self.reviews.size
347
+ end
348
+ end
349
+
350
+ if self.reviewable_caching_fields?(:average_rating)
351
+ self.cached_average_rating = self.average_rating(true)
352
+ # self.cached_average_rating = (self.cached_average_rating - review_rating) / self.cached_total_reviews.to_f
353
+ end
354
+
355
+ self.save_without_validation
356
+ else
357
+ raise RecordError, "Could not un-review #{review.inspect} by #{reviewer.inspect}: #{e}"
358
+ end
359
+ end
360
+
361
+ protected
362
+
363
+ # Checks if a certain value is a valid rating value for this reviewable object.
364
+ #
365
+ def valid_rating_value?(value_or_values)
366
+ value_or_values = [*value_or_values]
367
+ value_or_values.size == (value_or_values & self.rating_scale).size
368
+ end
369
+ alias :valid_rating_values? :valid_rating_value?
370
+
371
+ # Cachable fields for this reviewable class.
372
+ #
373
+ def reviewable_caching_fields
374
+ CACHABLE_FIELDS
375
+ end
376
+
377
+ # Checks if there are any cached fields for this reviewable class.
378
+ #
379
+ def reviewable_caching_fields?(*fields)
380
+ fields = CACHABLE_FIELDS if fields.blank?
381
+ fields.all? { |field| self.attributes.has_key?(:"cached_#{field}") }
382
+ end
383
+ alias :has_reviewable_caching_fields? :reviewable_caching_fields?
384
+
385
+ # Initialize any cached fields.
386
+ #
387
+ def init_reviewable_caching_fields
388
+ self.cached_total_reviews = 0 if self.reviewable_caching_fields?(:cached_total_reviews)
389
+ self.cached_average_rating = 0.0 if self.reviewable_caching_fields?(:average_rating)
390
+ end
391
+
392
+ def reviewable_conditions(as_array = false)
393
+ conditions = {:reviewable_id => self.id, :reviewable_type => self.class.name}
394
+ as_array ? Support.hash_conditions_as_array(conditions) : conditions
395
+ end
396
+
397
+ # Generate query conditions.
398
+ #
399
+ def reviewer_conditions(identifiers, as_array = false)
400
+ reviewer = self.validate_reviewer(identifiers)
401
+ if Support.is_active_record?(reviewer)
402
+ conditions = {:reviewer_id => reviewer.id, :reviewer_type => reviewer.class.name}
403
+ else
404
+ conditions = {:ip => reviewer.to_s}
405
+ end
406
+ as_array ? Support.hash_conditions_as_array(conditions) : conditions
407
+ end
408
+
409
+ def validate_reviewer(identifiers)
410
+ self.class.send(:validate_reviewer, identifiers)
411
+ end
412
+
413
+ end
414
+
415
+ module Finders
416
+
417
+ # When the has-many-through-polymoprhic issue is solved:
418
+ #
419
+ # * users that reviewed this with rating X
420
+ # * users that reviewed this, also reviewed [...] with same rating
421
+
422
+ end
423
+
424
+ end
425
+ end
426
+
427
+ # Extend ActiveRecord.
428
+ ::ActiveRecord::Base.class_eval do
429
+ include ::IsReviewable::Reviewable
430
+ end