is_reviewable 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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