recommendable 0.1.2

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.
Files changed (89) hide show
  1. data/.gitignore +57 -0
  2. data/.travis.yml +3 -0
  3. data/Gemfile +17 -0
  4. data/Gemfile.lock +118 -0
  5. data/LICENSE.txt +20 -0
  6. data/README.markdown +340 -0
  7. data/Rakefile +26 -0
  8. data/TODO +6 -0
  9. data/app/jobs/recommendable/recommendation_refresher.rb +12 -0
  10. data/app/models/recommendable/dislike.rb +9 -0
  11. data/app/models/recommendable/ignore.rb +9 -0
  12. data/app/models/recommendable/like.rb +9 -0
  13. data/app/models/recommendable/stashed_item.rb +9 -0
  14. data/config/routes.rb +3 -0
  15. data/db/migrate/20120124193723_create_likes.rb +17 -0
  16. data/db/migrate/20120124193728_create_dislikes.rb +17 -0
  17. data/db/migrate/20120127092558_create_ignores.rb +17 -0
  18. data/db/migrate/20120131173909_create_stashed_items.rb +17 -0
  19. data/lib/generators/recommendable/USAGE +8 -0
  20. data/lib/generators/recommendable/install_generator.rb +47 -0
  21. data/lib/generators/recommendable/templates/initializer.rb +18 -0
  22. data/lib/recommendable.rb +19 -0
  23. data/lib/recommendable/acts_as_recommendable.rb +85 -0
  24. data/lib/recommendable/acts_as_recommended_to.rb +659 -0
  25. data/lib/recommendable/engine.rb +13 -0
  26. data/lib/recommendable/exceptions.rb +4 -0
  27. data/lib/recommendable/railtie.rb +6 -0
  28. data/lib/recommendable/version.rb +3 -0
  29. data/lib/tasks/recommendable_tasks.rake +1 -0
  30. data/recommendable.gemspec +32 -0
  31. data/script/rails +8 -0
  32. data/spec/configuration_spec.rb +9 -0
  33. data/spec/dummy/README.rdoc +261 -0
  34. data/spec/dummy/Rakefile +7 -0
  35. data/spec/dummy/app/assets/javascripts/application.js +15 -0
  36. data/spec/dummy/app/assets/stylesheets/application.css +13 -0
  37. data/spec/dummy/app/controllers/application_controller.rb +3 -0
  38. data/spec/dummy/app/helpers/application_helper.rb +2 -0
  39. data/spec/dummy/app/mailers/.gitkeep +0 -0
  40. data/spec/dummy/app/models/.gitkeep +0 -0
  41. data/spec/dummy/app/models/bully.rb +2 -0
  42. data/spec/dummy/app/models/movie.rb +3 -0
  43. data/spec/dummy/app/models/php_framework.rb +2 -0
  44. data/spec/dummy/app/models/user.rb +3 -0
  45. data/spec/dummy/app/views/layouts/application.html.erb +14 -0
  46. data/spec/dummy/config.ru +4 -0
  47. data/spec/dummy/config/application.rb +56 -0
  48. data/spec/dummy/config/boot.rb +10 -0
  49. data/spec/dummy/config/database.yml +25 -0
  50. data/spec/dummy/config/environment.rb +5 -0
  51. data/spec/dummy/config/environments/development.rb +37 -0
  52. data/spec/dummy/config/environments/production.rb +67 -0
  53. data/spec/dummy/config/environments/test.rb +37 -0
  54. data/spec/dummy/config/initializers/backtrace_silencers.rb +7 -0
  55. data/spec/dummy/config/initializers/inflections.rb +15 -0
  56. data/spec/dummy/config/initializers/mime_types.rb +5 -0
  57. data/spec/dummy/config/initializers/recommendable.rb +16 -0
  58. data/spec/dummy/config/initializers/secret_token.rb +7 -0
  59. data/spec/dummy/config/initializers/session_store.rb +8 -0
  60. data/spec/dummy/config/initializers/wrap_parameters.rb +14 -0
  61. data/spec/dummy/config/locales/en.yml +5 -0
  62. data/spec/dummy/config/routes.rb +4 -0
  63. data/spec/dummy/db/migrate/20120128005553_create_likes.recommendable.rb +18 -0
  64. data/spec/dummy/db/migrate/20120128005554_create_dislikes.recommendable.rb +18 -0
  65. data/spec/dummy/db/migrate/20120128005555_create_ignores.recommendable.rb +18 -0
  66. data/spec/dummy/db/migrate/20120128020228_create_users.rb +9 -0
  67. data/spec/dummy/db/migrate/20120128020413_create_movies.rb +10 -0
  68. data/spec/dummy/db/migrate/20120128024632_create_php_frameworks.rb +9 -0
  69. data/spec/dummy/db/migrate/20120128024804_create_bullies.rb +9 -0
  70. data/spec/dummy/db/migrate/20120131195416_create_stashed_items.recommendable.rb +18 -0
  71. data/spec/dummy/db/schema.rb +89 -0
  72. data/spec/dummy/lib/assets/.gitkeep +0 -0
  73. data/spec/dummy/log/.gitkeep +0 -0
  74. data/spec/dummy/public/404.html +26 -0
  75. data/spec/dummy/public/422.html +26 -0
  76. data/spec/dummy/public/500.html +25 -0
  77. data/spec/dummy/public/favicon.ico +0 -0
  78. data/spec/dummy/recommendable_dummy_development +0 -0
  79. data/spec/dummy/recommendable_dummy_test +0 -0
  80. data/spec/dummy/script/rails +6 -0
  81. data/spec/factories.rb +16 -0
  82. data/spec/models/dislike_spec.rb +27 -0
  83. data/spec/models/ignore_spec.rb +27 -0
  84. data/spec/models/like_spec.rb +28 -0
  85. data/spec/models/stashed_item_spec.rb +27 -0
  86. data/spec/models/user_benchmark_spec.rb +49 -0
  87. data/spec/models/user_spec.rb +320 -0
  88. data/spec/spec_helper.rb +28 -0
  89. metadata +254 -0
data/Rakefile ADDED
@@ -0,0 +1,26 @@
1
+ # encoding: utf-8
2
+
3
+ require 'rubygems'
4
+ require 'bundler'
5
+
6
+ begin
7
+ Bundler.setup(:default, :development)
8
+ rescue Bundler::BundlerError => e
9
+ $stderr.puts e.message
10
+ $stderr.puts "Run `bundle install` to install missing gems"
11
+ exit e.status_code
12
+ end
13
+
14
+ require 'rake'
15
+
16
+ require 'rake/testtask'
17
+ Rake::TestTask.new(:test) do |test|
18
+ test.libs << 'lib' << 'spec'
19
+ test.pattern = 'spec/**/*_spec.rb'
20
+ test.verbose = true
21
+ end
22
+
23
+ task :default => :test
24
+
25
+ require 'yard'
26
+ YARD::Rake::YardocTask.new
data/TODO ADDED
@@ -0,0 +1,6 @@
1
+ = TODO
2
+
3
+ == Next update
4
+
5
+ * Make public methods to return likes/dislikes that are common between two users
6
+ * Allow the option NOT to queue up on like/dislike/ignore?
@@ -0,0 +1,12 @@
1
+ module Recommendable
2
+ class RecommendationRefresher
3
+ include Resque::Plugins::UniqueJob
4
+ @queue = :recommendable
5
+
6
+ def self.perform(user_id)
7
+ user = Recommendable.user_class.find(user_id)
8
+ user.send :update_similarities
9
+ user.send :update_recommendations
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,9 @@
1
+ module Recommendable
2
+ class Dislike < ActiveRecord::Base
3
+ belongs_to :user, :class_name => Recommendable.user_class.to_s
4
+ belongs_to :dislikeable, :polymorphic => :true
5
+
6
+ validates :user_id, :uniqueness => { :scope => [:dislikeable_id, :dislikeable_type],
7
+ :message => "has already disliked this item" }
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ module Recommendable
2
+ class Ignore < ActiveRecord::Base
3
+ belongs_to :user, :class_name => Recommendable.user_class.to_s
4
+ belongs_to :ignoreable, :polymorphic => :true
5
+
6
+ validates :user_id, :uniqueness => { :scope => [:ignoreable_id, :ignoreable_type],
7
+ :message => "has already ignored this item" }
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ module Recommendable
2
+ class Like < ActiveRecord::Base
3
+ belongs_to :user, :class_name => Recommendable.user_class.to_s
4
+ belongs_to :likeable, :polymorphic => :true
5
+
6
+ validates :user_id, :uniqueness => { :scope => [:likeable_id, :likeable_type],
7
+ :message => "has already liked this item" }
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ module Recommendable
2
+ class StashedItem < ActiveRecord::Base
3
+ belongs_to :user, :class_name => Recommendable.user_class.to_s
4
+ belongs_to :stashable, :polymorphic => :true
5
+
6
+ validates :user_id, :uniqueness => { :scope => [:stashable_id, :stashable_type],
7
+ :message => "has already stashed this item" }
8
+ end
9
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,3 @@
1
+ Recommendable::Engine.routes.draw do
2
+
3
+ end
@@ -0,0 +1,17 @@
1
+ class CreateLikes < ActiveRecord::Migration
2
+ def up
3
+ create_table :likes do |t|
4
+ t.references :user
5
+ t.references :likeable, :polymorphic => true
6
+ t.timestamps
7
+ end
8
+
9
+ add_index :likes, :likeable_id
10
+ add_index :likes, :likeable_type
11
+ add_index :likes, [:user_id, :likeable_id, :likeable_type], :unique => true, :name => "user_like_constraint"
12
+ end
13
+
14
+ def down
15
+ drop_table :likes
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ class CreateDislikes < ActiveRecord::Migration
2
+ def up
3
+ create_table :dislikes do |t|
4
+ t.references :user
5
+ t.references :dislikeable, :polymorphic => true
6
+ t.timestamps
7
+ end
8
+
9
+ add_index :dislikes, :dislikeable_id
10
+ add_index :dislikes, :dislikeable_type
11
+ add_index :dislikes, [:user_id, :dislikeable_id, :dislikeable_type], :unique => true, :name => "user_dislike_constraint"
12
+ end
13
+
14
+ def down
15
+ drop_table :dislikes
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ class CreateIgnores < ActiveRecord::Migration
2
+ def up
3
+ create_table :ignores do |t|
4
+ t.references :user
5
+ t.references :ignoreable, :polymorphic => true
6
+ t.timestamps
7
+ end
8
+
9
+ add_index :ignores, :ignoreable_id
10
+ add_index :ignores, :ignoreable_type
11
+ add_index :ignores, [:user_id, :ignoreable_id, :ignoreable_type], :unique => true, :name => "user_ignore_constraint"
12
+ end
13
+
14
+ def down
15
+ drop_table :ignores
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ class CreateStashedItems < ActiveRecord::Migration
2
+ def up
3
+ create_table :stashed_items do |t|
4
+ t.references :user
5
+ t.references :stashable, :polymorphic => true
6
+ t.timestamps
7
+ end
8
+
9
+ add_index :stashed_items, :stashable_id
10
+ add_index :stashed_items, :stashable_type
11
+ add_index :stashed_items, [:user_id, :stashable_id, :stashable_type], :unique => true, :name => "user_stashed_constraint"
12
+ end
13
+
14
+ def down
15
+ drop_table :stashed_items
16
+ end
17
+ end
@@ -0,0 +1,8 @@
1
+ Description:
2
+ This generator will install Recommendable's initializer.rb file and migrate the Like and Dislike tables into your database unless specified.
3
+
4
+ Example:
5
+ rails generate recommendable:install --user-class=YourUserClass
6
+
7
+ This will create:
8
+ config/initializers/recommendable.rb
@@ -0,0 +1,47 @@
1
+ require 'rails/generators'
2
+
3
+ module Recommendable
4
+ module Generators
5
+ class InstallGenerator < Rails::Generators::Base
6
+ argument :user_model, :type => :string, :default => "User", :desc => "Your user model that will be liking and disliking objects."
7
+ argument :redis_host, :type => :string, :default => "localhost", :desc => "The hostname your redis server is running on."
8
+ argument :redis_port, :type => :string, :default => "6379", :desc => "The port your redis server is running on."
9
+ class_option :redis_socket, :type => :string, :desc => "Indicates the UNIX socket your redis server is running on (if it is)."
10
+ class_option :no_migrate, :type => :boolean, :default => false, :desc => "Skip migrations. The Like and Dislike tables will not be created."
11
+
12
+ source_root File.expand_path("../templates", __FILE__)
13
+
14
+ def add_recommendable_initializer
15
+ path = "#{Rails.root}/config/initializers/recommendable.rb"
16
+ if File.exists?(path)
17
+ puts "Skipping config/initializers/recommendable.rb creation; file already exists!"
18
+ else
19
+ puts "Adding Recommendable initializer (config/initializers/recommendable.rb)"
20
+ template "initializer.rb", path
21
+ end
22
+ end
23
+
24
+ def install_migrations
25
+ puts "Copying migrations..."
26
+ Dir.chdir(Rails.root) { puts `rake recommendable:install:migrations` }
27
+ end
28
+
29
+ def run_migrations
30
+ unless options[:no_migrate]
31
+ puts "Running rake db:migrate"
32
+ puts `rake db:migrate`
33
+ end
34
+ end
35
+
36
+ def finished
37
+ puts "Done! Recommendable has been successfully installed. Please configure it in config/intializers/recommendable.rb"
38
+ end
39
+
40
+ private
41
+
42
+ def user_class
43
+ user_model.camelize
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,18 @@
1
+ require "redis"
2
+ require "resque"
3
+ require "resque-loner"
4
+
5
+ # What class will be liking/disliking objects and receiving recommendations?
6
+ Recommendable.user_class = "<%= user_class %>"
7
+
8
+ # Recommendable requires a connection to a running redis-server. Either create
9
+ # a new instance based on a host/port or UNIX socket, or pass in an existing
10
+ # Redis client instance.
11
+ <% if options.redis_socket %># <% end %>Recommendable.redis = Redis.new(:host => "<%= redis_host %>", :port => <%= redis_port %>)
12
+
13
+ # Connect to Redis via a UNIX socket instead
14
+ <% unless options.redis_socket %># <% end %>Recommendable.redis = Redis.new(:sock => "<%= options.redis_socket %>")
15
+
16
+ # Tell Redis which database to use (usually between 0 and 15). The default of 0
17
+ # is most likely okay unless you have another application using that database.
18
+ Recommendable.redis.select "0"
@@ -0,0 +1,19 @@
1
+ require 'recommendable/engine'
2
+ require 'recommendable/acts_as_recommended_to'
3
+ require 'recommendable/acts_as_recommendable'
4
+ require 'recommendable/exceptions'
5
+ require 'recommendable/railtie' if defined?(Rails)
6
+ require 'recommendable/version'
7
+
8
+ module Recommendable
9
+ mattr_accessor :redis, :user_class
10
+ mattr_writer :user_class, :recommendable_classes
11
+
12
+ def self.user_class
13
+ @@user_class.camelize.constantize
14
+ end
15
+
16
+ def self.recommendable_classes
17
+ @@recommendable_classes ||= []
18
+ end
19
+ end
@@ -0,0 +1,85 @@
1
+ module Recommendable
2
+ module ActsAsRecommendable
3
+ extend ActiveSupport::Concern
4
+
5
+ module ClassMethods
6
+ def acts_as_recommendable
7
+ class_eval do
8
+ Recommendable.recommendable_classes << self
9
+
10
+ has_many :likes, :as => :likeable, :dependent => :destroy, :class_name => "Recommendable::Like"
11
+ has_many :dislikes, :as => :dislikeable, :dependent => :destroy, :class_name => "Recommendable::Dislike"
12
+ has_many :liked_by, :through => :likes, :source => :user
13
+ has_many :disliked_by, :through => :dislikes, :source => :user
14
+ has_many :ignores, :as => :ignoreable, :dependent => :destroy, :class_name => "Recommendable::Ignore"
15
+ has_many :stashes, :as => :stashable, :dependent => :destroy, :class_name => "Recommendable::StashedItem"
16
+
17
+ include LikeableMethods
18
+ include DislikeableMethods
19
+
20
+ def self.acts_as_recommendable? ; true ; end
21
+
22
+ def has_been_rated?
23
+ likes.count + dislikes.count > 0
24
+ end
25
+
26
+ # Used for setup purposes. Calls convenience methods to create sets
27
+ # in redis of users that both like and dislike this object.
28
+ # @return [Array] an array containing the liked_by set and the disliked_by set
29
+ # @private
30
+ def create_recommendable_sets
31
+ [create_liked_by_set, create_disliked_by_set]
32
+ end
33
+
34
+ # Used for teardown purposes. Destroys the sets in redis created by
35
+ # {#create_recommendable_sets}
36
+ # @private
37
+ def destroy_recommendable_sets
38
+ Recommendable.redis.del "#{self.class}:#{id}:liked_by"
39
+ Recommendable.redis.del "#{self.class}:#{id}:disliked_by"
40
+ end
41
+
42
+ private :likes, :dislikes, :ignores, :stashes,
43
+ :create_recommendable_sets, :destroy_recommendable_sets
44
+ end
45
+ end
46
+
47
+ def acts_as_recommendable? ; false ; end
48
+ end
49
+
50
+ # Instance methods.
51
+ def recommendable? ; self.class.acts_as_recommendable? ; end
52
+
53
+ def redis_key ; "#{self.class}:#{id}" ; end
54
+
55
+ protected :redis_key
56
+
57
+ module LikeableMethods
58
+ # Used for setup purposes. Creates a set in redis containing users that
59
+ # have liked this object.
60
+ # @private
61
+ # @return [String] the key in Redis pointing to the set
62
+ def create_liked_by_set
63
+ set = "#{self.class}:#{id}:liked_by"
64
+ liked_by.each {|rater| Recommendable.redis.sadd set, rater.id}
65
+ return set
66
+ end
67
+
68
+ private :create_liked_by_set
69
+ end
70
+
71
+ module DislikeableMethods
72
+ # Used for setup purposes. Creates a set in redis containing users that
73
+ # have disliked this object.
74
+ # @private
75
+ # @return [String] the key in Redis pointing to the set
76
+ def create_disliked_by_set
77
+ set = "#{self.class}:#{id}:disliked_by"
78
+ disliked_by.each {|rater| Recommendable.redis.sadd set, rater.id}
79
+ return set
80
+ end
81
+
82
+ private :create_disliked_by_set
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,659 @@
1
+ require 'active_support/concern'
2
+
3
+ module Recommendable
4
+ module ActsAsRecommendedTo
5
+ extend ActiveSupport::Concern
6
+
7
+ module ClassMethods
8
+ def acts_as_recommended_to
9
+ class_eval do
10
+ has_many :likes, :class_name => "Recommendable::Like", :dependent => :destroy
11
+ has_many :dislikes, :class_name => "Recommendable::Dislike", :dependent => :destroy
12
+ has_many :ignores, :class_name => "Recommendable::Ignore", :dependent => :destroy
13
+ has_many :stashed_items, :class_name => "Recommendable::StashedItem", :dependent => :destroy
14
+
15
+ include LikeMethods
16
+ include DislikeMethods
17
+ include StashMethods
18
+ include IgnoreMethods
19
+ include RecommendationMethods
20
+
21
+ def self.acts_as_recommended_to? ; true ; end
22
+
23
+ private :likes, :dislikes, :ignores, :stashed_items
24
+ end
25
+ end
26
+
27
+ def acts_as_recommended_to? ; false ; end
28
+ end
29
+
30
+ # Instance method.
31
+ def can_rate? ; self.class.acts_as_recommended_to? ; end
32
+
33
+ module LikeMethods
34
+ # Creates a Recommendable::Like to associate self to a passed object. If
35
+ # self is currently found to have disliked object, the corresponding
36
+ # Recommendable::Dislike will be destroyed. It will also be removed from
37
+ # the user's stash or ignores.
38
+ #
39
+ # @param [Object] object the object you want self to like.
40
+ # @return true if object has been liked
41
+ # @raise [RecordNotRecommendableError] if you have not declared the passed object's model to `act_as_recommendable`
42
+ def like(object)
43
+ raise RecordNotRecommendableError unless object.recommendable?
44
+ return if likes?(object)
45
+ undislike(object)
46
+ unstash(object)
47
+ unignore(object)
48
+ unpredict(object)
49
+ likes.create!(:likeable_id => object.id, :likeable_type => object.class.to_s)
50
+ Resque.enqueue RecommendationRefresher, self.id
51
+ true
52
+ end
53
+
54
+ # Checks to see if self has already liked a passed object.
55
+ #
56
+ # @param [Object] object the object you want to check
57
+ # @return true if self likes object, false if not
58
+ def likes?(object)
59
+ likes.exists?(:likeable_id => object.id, :likeable_type => object.class.to_s)
60
+ end
61
+
62
+ # Destroys a Recommendable::Like currently associating self with object
63
+ #
64
+ # @param [Object] object the object you want to remove from self's likes
65
+ # @return true if object is unliked, nil if nothing happened
66
+ def unlike(object)
67
+ if likes.where(:likeable_id => object.id, :likeable_type => object.class.to_s).first.try(:destroy)
68
+ Resque.enqueue RecommendationRefresher, self.id
69
+ true
70
+ end
71
+ end
72
+
73
+ # Get a list of records that self currently likes
74
+
75
+ # @return [Array] an array of ActiveRecord objects that self has liked
76
+ def liked
77
+ likes.map {|like| like.likeable}
78
+ end
79
+
80
+ alias_method :liked_records, :liked
81
+
82
+ # Get a list of Recommendable::Likes with a `#likeable_type` of the passed
83
+ # class.
84
+ #
85
+ # @param [Class, String, Symbol] klass the class for which you would like to return self's likes. Can be the class constant, or a String/Symbol representation of the class name.
86
+ # @note You should not need to use this method. (see {#liked_for})
87
+ # @private
88
+ def likes_for(klass)
89
+ likes.where(:likeable_type => klassify(klass).to_s)
90
+ end
91
+
92
+ # Get a list of records belonging to a passed class that self currently
93
+ # likes.
94
+ #
95
+ # @param [Class, String, Symbol] klass the class of records. Can be the class constant, or a String/Symbol representation of the class name.
96
+ # @return [Array] an array of ActiveRecord objects that self has liked belonging to klass
97
+ def liked_for(klass)
98
+ klassify(klass).find likes_for(klass).map(&:likeable_id)
99
+ end
100
+
101
+ alias_method :liked_records_for, :liked_for
102
+ private :likes_for
103
+ end
104
+
105
+ module DislikeMethods
106
+ # Creates a Recommendable::Dislike to associate self to a passed object. If
107
+ # self is currently found to have liked object, the corresponding
108
+ # Recommendable::Like will be destroyed. It will also be removed from the
109
+ # user's stash or list of ignores.
110
+ #
111
+ # @param [Object] object the object you want self to dislike.
112
+ # @return true if object has been disliked
113
+ # @raise [RecordNotRecommendableError] if you have not declared the passed object's model to `act_as_recommendable`
114
+ def dislike(object)
115
+ raise RecordNotRecommendableError unless object.recommendable?
116
+ return if dislikes?(object)
117
+ unlike(object)
118
+ unstash(object)
119
+ unignore(object)
120
+ unpredict(object)
121
+ dislikes.create!(:dislikeable_id => object.id, :dislikeable_type => object.class.to_s)
122
+ Resque.enqueue RecommendationRefresher, self.id
123
+ true
124
+ end
125
+
126
+ # Checks to see if self has already disliked a passed object.
127
+ #
128
+ # @param [Object] object the object you want to check
129
+ # @return true if self dislikes object, false if not
130
+ def dislikes?(object)
131
+ dislikes.exists?(:dislikeable_id => object.id, :dislikeable_type => object.class.to_s)
132
+ end
133
+
134
+ # Destroys a Recommendable::Dislike currently associating self with object
135
+ #
136
+ # @param [Object] object the object you want to remove from self's dislikes
137
+ # @return true if object is removed from self's dislikes, nil if nothing happened
138
+ def undislike(object)
139
+ if dislikes.where(:dislikeable_id => object.id, :dislikeable_type => object.class.to_s).first.try(:destroy)
140
+ Resque.enqueue RecommendationRefresher, self.id
141
+ true
142
+ end
143
+ end
144
+
145
+ # Get a list of records that self currently dislikes
146
+
147
+ # @return [Array] an array of ActiveRecord objects that self has disliked
148
+ def disliked
149
+ dislikes.map {|dislike| dislike.dislikeable}
150
+ end
151
+
152
+ alias_method :disliked_records, :disliked
153
+
154
+ # Get a list of Recommendable::Dislikes with a `#dislikeable_type` of the
155
+ # passed class.
156
+ #
157
+ # @param [Class, String, Symbol] klass the class for which you would like to return self's dislikes. Can be the class constant, or a String/Symbol representation of the class name.
158
+ # @note You should not need to use this method. (see {#disliked_for})
159
+ # @private
160
+ def dislikes_for(klass)
161
+ dislikes.where(:dislikeable_type => klassify(klass).to_s)
162
+ end
163
+
164
+ # Get a list of records belonging to a passed class that self currently
165
+ # dislikes.
166
+ #
167
+ # @param [Class, String, Symbol] klass the class of records. Can be the class constant, or a String/Symbol representation of the class name.
168
+ # @return [Array] an array of ActiveRecord objects that self has disliked belonging to klass
169
+ def disliked_for(klass)
170
+ klassify(klass).find dislikes_for(klass).map(&:dislikeable_id)
171
+ end
172
+
173
+ alias_method :disliked_records_for, :disliked_for
174
+ private :dislikes_for
175
+ end
176
+
177
+ module StashMethods
178
+ # Creates a Recommendable::StashedItem to associate self to a passed object.
179
+ # This will remove the item from this user's recommendations.
180
+ # If self is currently found to have liked or disliked the object, nothing
181
+ # will happen. It will, however, be unignored.
182
+ #
183
+ # @param [Object] object the object you want self to stash.
184
+ # @return true if object has been stashed
185
+ # @raise [RecordNotRecommendableError] if you have not declared the passed object's model to `act_as_recommendable`
186
+ def stash(object)
187
+ raise RecordNotRecommendableError unless object.recommendable?
188
+ return if has_rated?(object) || has_stashed?(object)
189
+ unignore(object)
190
+ unpredict(object)
191
+ stashed_items.create!(:stashable_id => object.id, :stashable_type => object.class.to_s)
192
+ true
193
+ end
194
+
195
+ # Checks to see if self has already stashed a passed object for later.
196
+ #
197
+ # @param [Object] object the object you want to check
198
+ # @return true if self has stashed object, false if not
199
+ def has_stashed?(object)
200
+ stashed_items.exists?(:stashable_id => object.id, :stashable_type => object.class.to_s)
201
+ end
202
+
203
+ # Destroys a Recommendable::StashedItem currently associating self with object
204
+ #
205
+ # @param [Object] object the object you want to remove from self's stash
206
+ # @return true if object is stashed, nil if nothing happened
207
+ def unstash(object)
208
+ true if stashed_items.where(:stashable_id => object.id, :stashable_type => object.class.to_s).first.try(:destroy)
209
+ end
210
+
211
+ # Get a list of records that self has currently stashed for later
212
+
213
+ # @return [Array] an array of ActiveRecord objects that self has stashed
214
+ def stashed
215
+ stashed_items.map {|item| item.stashable}
216
+ end
217
+
218
+ alias_method :stashed_records, :stashed
219
+
220
+ # Get a list of Recommendable::StashedItems with a stashable_type of the
221
+ # passed class.
222
+ #
223
+ # @param [Class, String, Symbol] klass the class for which you would like to return self's stashed items. Can be the class constant, or a String/Symbol representation of the class name.
224
+ # @note You should not need to use this method. (see {#stashed_for})
225
+ # @private
226
+ def stash_for(klass)
227
+ stashed_items.where(:stashable_type => klassify(klass).to_s)
228
+ end
229
+
230
+ # Get a list of records belonging to a passed class that self currently
231
+ # has stashed away for later.
232
+ #
233
+ # @param [Class, String, Symbol] klass the class of records. Can be the class constant, or a String/Symbol representation of the class name.
234
+ # @return [Array] an array of ActiveRecord objects that self has stashed belonging to klass
235
+ def stashed_for(klass)
236
+ klassify(klass).find stash_for(klass).map(&:stashable_id)
237
+ end
238
+
239
+ alias_method :stashed_records_for, :stashed_for
240
+ private :stash_for
241
+ end
242
+
243
+ module IgnoreMethods
244
+ # Creates a Recommendable::Ignore to associate self to a passed object. If
245
+ # self is currently found to have liked or dislikedobject, the
246
+ # corresponding Recommendable::Like or Recommendable::Dislike will be
247
+ # destroyed.
248
+ #
249
+ # @param [Object] object the object you want self to ignore.
250
+ # @return true if object has been ignored
251
+ # @raise [RecordNotRecommendableError] if you have not declared the passed object's model to `act_as_recommendable`
252
+ def ignore(object)
253
+ raise RecordNotRecommendableError unless object.recommendable?
254
+ return if has_ignored?(object)
255
+ unlike(object)
256
+ undislike(object)
257
+ unstash(object)
258
+ unpredict(object)
259
+ ignores.create!(:ignoreable_id => object.id, :ignoreable_type => object.class.to_s)
260
+ true
261
+ end
262
+
263
+ # Checks to see if self has already ignored a passed object.
264
+ #
265
+ # @param [Object] object the object you want to check
266
+ # @return true if self has ignored object, false if not
267
+ def has_ignored?(object)
268
+ ignores.exists?(:ignoreable_id => object.id, :ignoreable_type => object.class.to_s)
269
+ end
270
+
271
+ # Destroys a Recommendable::Ignore currently associating self with object
272
+ #
273
+ # @param [Object] object the object you want to remove from self's ignores
274
+ # @return true if object is removed from self's ignores, nil if nothing happened
275
+ def unignore(object)
276
+ true if ignores.where(:ignoreable_id => object.id, :ignoreable_type => object.class.to_s).first.try(:destroy)
277
+ end
278
+
279
+ # Get a list of records that self is currently ignoring
280
+
281
+ # @return [Array] an array of ActiveRecord objects that self has ignored
282
+ def ignored
283
+ ignores.map {|ignore| ignore.ignoreable}
284
+ end
285
+
286
+ alias_method :ignored_records, :ignored
287
+
288
+ # Get a list of Recommendable::Ignores with a `#ignoreable_type` of the
289
+ # passed class.
290
+ #
291
+ # @param [Class, String, Symbol] klass the class for which you would like to return self's ignores. Can be the class constant, or a String/Symbol representation of the class name.
292
+ # @note You should not need to use this method. (see {#ignored_for})
293
+ # @private
294
+ def ignores_for(klass)
295
+ ignores.where(:ignoreable_type => klassify(klass).to_s)
296
+ end
297
+
298
+ # Get a list of records belonging to a passed class that self is
299
+ # currently ignoring.
300
+ #
301
+ # @param [Class, String, Symbol] klass the class of records. Can be the class constant, or a String/Symbol representation of the class name.
302
+ # @return [Array] an array of ActiveRecord objects that self has ignored belonging to klass
303
+ def ignored_for(klass)
304
+ klassify(klass).find ignores_for(klass).map(&:ignoreable_id)
305
+ end
306
+
307
+ alias_method :ignored_records_for, :ignored_for
308
+ private :ignores_for
309
+ end
310
+
311
+ module RecommendationMethods
312
+ def self.acts_as_recommended_to? ; true ; end
313
+
314
+ def can_receive_recommendations? ; self.class.acts_as_recommended_to? ; end
315
+
316
+ # Checks to see if self has already liked or disliked a passed object.
317
+ #
318
+ # @param [Object] object the object you want to check
319
+ # @return true if self has liked or disliked object, false if not
320
+ def has_rated?(object)
321
+ likes?(object) || dislikes?(object)
322
+ end
323
+
324
+ # Checks to see if self has liked or disliked any objects yet.
325
+ #
326
+ # @return true if self has liked or disliked anything, false if not
327
+ def has_rated_anything?
328
+ likes.count > 0 || dislikes.count > 0
329
+ end
330
+
331
+ # Get a list of raters that have been found to be the most similar to
332
+ # self. They are sorted in a descending fashion with the most similar
333
+ # rater in the first index.
334
+ #
335
+ # @param [Hash] options the options for this query
336
+ # @option options [Fixnum] :count (10) The number of raters to return
337
+ # @return [Array] An array of instances of your user class
338
+ def similar_raters(options = {})
339
+ defaults = { :count => 10 }
340
+ options = defaults.merge(options)
341
+
342
+ rater_ids = Recommendable.redis.zrevrange(similarity_set, 0, options[:count] - 1).map(&:to_i)
343
+ raters = Recommendable.user_class.where("ID IN (?)", rater_ids)
344
+
345
+ # The query loses the ordering, so...
346
+ return raters.sort do |x, y|
347
+ rater_ids.index(x.id) <=> rater_ids.index(y.id)
348
+ end
349
+ end
350
+
351
+ # Get a list of recommendations for self. The whole point of this gem!
352
+ # Recommendations are returned in a descending order with the first index
353
+ # being the object that self has been found most likely to enjoy.
354
+ #
355
+ # @param [Hash] options the options for returning this list
356
+ # @option options [Fixnum] :count (10) the number of recommendations to get
357
+ # @return [Array] an array of ActiveRecord objects that are recommendable
358
+ def recommendations(options = {})
359
+ defaults = { :count => 10 }
360
+ options = defaults.merge options
361
+ return [] if likes.count + dislikes.count == 0
362
+
363
+ unioned_predictions = "#{self.class}:#{id}:predictions"
364
+ Recommendable.redis.zunionstore unioned_predictions, Recommendable.recommendable_classes.map {|klass| predictions_set_for(klass)}
365
+ return [] if Recommendable.redis.zcard(unioned_predictions) == 0
366
+
367
+ recommendations = Recommendable.redis.zrevrange(unioned_predictions, 0, options[:count]).map do |object|
368
+ klass, id = object.split(":")
369
+ klass.constantize.find(id)
370
+ end
371
+
372
+ Recommendable.redis.del unioned_predictions
373
+ return recommendations
374
+ end
375
+
376
+ # Get a list of recommendations for self on a single recommendable type.
377
+ # Recommendations are returned in a descending order with the first index
378
+ # being the object that self has been found most likely to enjoy.
379
+ #
380
+ # @param [Class, String, Symbol] klass the class to receive recommendations for. Can be the class constant, or a String/Symbol representation of the class name.
381
+ # @param [Hash] options the options for returning this list
382
+ # @option options [Fixnum] :count (10) the number of recommendations to get
383
+ # @return [Array] an array of ActiveRecord objects that are recommendable
384
+ def recommendations_for(klass, options = {})
385
+ defaults = { :count => 10 }
386
+ options = defaults.merge options
387
+
388
+ recommendations = []
389
+ return recommendations if likes_for(klass).count + dislikes_for(klass).count == 0 || Recommendable.redis.zcard(predictions_set_for(klass)) == 0
390
+
391
+ i = 0
392
+ until recommendations.size == options[:count]
393
+ prediction = Recommendable.redis.zrevrange(predictions_set_for(klass), i, i).first
394
+ return recommendations unless prediction # User might not have enough recommendations to return
395
+
396
+ object = klassify(klass).find(prediction.split(":")[1])
397
+ recommendations << object unless has_ignored?(object)
398
+ i += 1
399
+ end
400
+
401
+ return recommendations
402
+ end
403
+
404
+ # Return the value calculated by {#predict} on self for a passed object.
405
+ #
406
+ # @param [Object] object the object to fetch the probability for
407
+ # @return [Float] the likelihood of self liking the passed object
408
+ def probability_of_liking(object)
409
+ Recommendable.redis.zscore predictions_set_for(object.class), object.redis_key
410
+ end
411
+
412
+ # Return the negation of the value calculated by {#predict} on self
413
+ # for a passed object.
414
+ #
415
+ # @param [Object] object the object to fetch the probability for
416
+ # @return [Float] the likelihood of self disliking the passed object
417
+ # @see #probability of liking
418
+ def probability_of_disliking(object)
419
+ -probability_of_liking(object)
420
+ end
421
+
422
+ # Checks how similar a passed rater is with self. This method calculates
423
+ # a numeric similarity value that can fall between -1.0 and 1.0. A value of
424
+ # 1.0 indicates that rater has the exact same likes and dislikes as self
425
+ # while a value of -1.0 indicates that rater dislikes every object that self
426
+ # likes and likes every object that self dislikes. A value of 0.0 would
427
+ # indicate that the two users share no likes or dislikes.
428
+ #
429
+ # @param [Object] rater an ActiveRecord object declared to `act_as_recommendable_to`
430
+ # @return [Float] the numeric similarity between self and rater
431
+ # @note The returned value relies on which user the method is called on. current_user.similarity_with(rater) will not equal rater.similarity_with(current_user) unless their sets of likes and dislikes are identical. current_user.similarity_with(rater) will return 1.0 even if rater has several likes/dislikes that `current_user` does not.
432
+ # @private
433
+ def similarity_with(rater)
434
+ rater.create_recommended_to_sets
435
+ agreements = common_likes_with(rater, :return_records => false).size
436
+ agreements += common_dislikes_with(rater, :return_records => false).size
437
+ disagreements = disagreements_with(rater).size
438
+
439
+ similarity = (agreements - disagreements).to_f / (likes.count + dislikes.count)
440
+ rater.destroy_recommended_to_sets
441
+
442
+ return similarity
443
+ end
444
+ # Makes a call to Redis and intersects the sets of likes belonging to self
445
+ # and rater.
446
+ #
447
+ # @param [Object] rater the person whose set of likes you wish to intersect with that of self
448
+ # @param [Hash] options the options for this intersection
449
+ # @option options [Class, String, Symbol] :class ('nil') Restrict the intersection to a single recommendable type. By default, all recomendable types are considered
450
+ # @option options [true, false] :return_records (true) Return the actual Model instances
451
+ # @return [Array] An array of IDs, or strings from Redis in the form of "#{likeable_type}:#{id}" if options[:class] is set
452
+ def common_likes_with(rater, options = {})
453
+ defaults = { :class => nil,
454
+ :return_records => true }
455
+ options = defaults.merge(options)
456
+
457
+ if options[:class]
458
+ in_common = Recommendable.redis.sinter likes_set_for(options[:class]), rater.likes_set_for(options[:class])
459
+ klassify(options[:class]).find in_common if options[:return_records]
460
+ else
461
+ Recommendable.recommendable_classes.flat_map do |klass|
462
+ in_common = Recommendable.redis.sinter(likes_set_for(klass), rater.likes_set_for(klass))
463
+
464
+ if options[:return_records]
465
+ klassify(klass).find in_common if options[:return_records]
466
+ else
467
+ in_common.map {|id| "#{klassify(klass)}:#{id}"}
468
+ end
469
+ end
470
+ end
471
+ end
472
+
473
+ # Makes a call to Redis and intersects the sets of dislikes belonging to
474
+ # self and rater.
475
+ #
476
+ # @param [Object] rater the person whose set of dislikes you wish to intersect with that of self
477
+ # @param [Hash] options the options for this intersection
478
+ # @option options [Class, String, Symbol] :class ('nil') Restrict the intersection to a single recommendable type. By default, all recomendable types are considered
479
+ # @option options [true, false] :return_records (true) Return the actual Model instances
480
+ # @return [Array] An array of IDs, or strings from Redis in the form of #{dislikeable_type}:#{id}" if options[:class] is set
481
+ def common_dislikes_with(rater, options = {})
482
+ defaults = { :class => nil,
483
+ :return_records => true }
484
+ options = defaults.merge(options)
485
+
486
+ if options[:class]
487
+ in_common = Recommendable.redis.sinter dislikes_set_for(options[:class]), rater.dislikes_set_for(options[:class])
488
+ klassify(options[:class]).find in_common if options[:return_records]
489
+ else
490
+ Recommendable.recommendable_classes.flat_map do |klass|
491
+ in_common = Recommendable.redis.sinter(dislikes_set_for(klass), rater.dislikes_set_for(klass))
492
+
493
+ if options[:return_records]
494
+ klassify(klass).find in_common if options[:return_records]
495
+ else
496
+ in_common.map {|id| "#{klassify(klass)}:#{id}"}
497
+ end
498
+ end
499
+ end
500
+ end
501
+
502
+ # Makes a call to Redis and intersects self's set of likes with rater's
503
+ # set of dislikes and vise versa. The idea here is that if self likes
504
+ # an object that rater dislikes, it is a disagreement and should count
505
+ # negatively towards their similarity.
506
+ #
507
+ # @param [Object] rater the person whose sets you wish to intersect with those of self
508
+ # @param [Hash] options the options for this intersection
509
+ # @option options [Class, String, Symbol] :class ('nil') Restrict the intersections to a single recommendable type. By default, all recomendable types are considered
510
+ # @option options [true, false] :return_records (true) Return the actual Model instances
511
+ # @return [Array] An array of IDs, or strings from Redis in the form of #{recommendable_type}:#{id}" if options[:class] is set
512
+ def disagreements_with(rater, options = {})
513
+ defaults = { :class => nil,
514
+ :return_records => true }
515
+ options = defaults.merge(options)
516
+
517
+ if options[:class]
518
+ disagreements = Recommendable.redis.sinter(likes_set_for(options[:class]), rater.likes_set_for(options[:class]))
519
+ disagreements += Recommendable.redis.sinter(dislikes_set_for(options[:class]), rater.dislikes_set_for(options[:class]))
520
+ klassify(options[:class]).find disagreements if options[:return_records]
521
+ else
522
+ Recommendable.recommendable_classes.flat_map do |klass|
523
+ disagreements = Recommendable.redis.sinter(likes_set_for(klass), rater.likes_set_for(klass))
524
+ disagreements += Recommendable.redis.sinter(dislikes_set_for(klass), rater.dislikes_set_for(klass))
525
+
526
+ if options[:return_records]
527
+ klassify(klass).find disagreements
528
+ else
529
+ disagreements.map {|id| "#{klassify(klass)}:#{id}"}
530
+ end
531
+ end
532
+ end
533
+ end
534
+
535
+ # Predict how likely it is that self will like a passed in object. This
536
+ # probability is not based on percentage. 0.0 indicates that self will
537
+ # neither like nor dislike the passed object. Values that approach Infinity
538
+ # indicate a rising probability of liking the passed object while values
539
+ # approaching -Infinity indicate a rising probability of disliking the
540
+ # passed object.
541
+ #
542
+ # @param [Object] object the object to check the likeliness of liking
543
+ # @return [Float] the probability that self will like object
544
+ # @private
545
+ def predict(object)
546
+ liked_by, disliked_by = object.send :create_recommendable_sets
547
+ rated_by = Recommendable.redis.scard(liked_by) + Recommendable.redis.scard(disliked_by)
548
+ similarity_sum = 0.0
549
+ prediction = 0.0
550
+
551
+ Recommendable.redis.smembers(liked_by).inject(similarity_sum) {|sum, r| sum += Recommendable.redis.zscore(similarity_set, r).to_f }
552
+ Recommendable.redis.smembers(disliked_by).inject(similarity_sum) {|sum, r| sum -= Recommendable.redis.zscore(similarity_set, r).to_f }
553
+
554
+ prediction = similarity_sum / rated_by.to_f
555
+
556
+ object.send :destroy_recommendable_sets
557
+
558
+ return prediction
559
+ end
560
+
561
+ # Used internally to update the similarity values between self and all
562
+ # other users. This is called in the Resque job to refresh recommendations.
563
+ #
564
+ # @private
565
+ def update_similarities
566
+ return unless has_rated_anything?
567
+ create_recommended_to_sets
568
+
569
+ Recommendable.user_class.find_each do |rater|
570
+ next if self == rater || !rater.can_rate?
571
+ Recommendable.redis.zadd similarity_set, similarity_with(rater), "#{rater.id}"
572
+ end
573
+
574
+ destroy_recommended_to_sets
575
+ end
576
+
577
+ # Used internally to update self's prediction values across all
578
+ # recommendable types. This is called in the Resque job to refresh
579
+ # recommendations.
580
+ #
581
+ # @private
582
+ def update_recommendations
583
+ Recommendable.recommendable_classes.each do |klass|
584
+ update_recommendations_for(klass)
585
+ end
586
+ end
587
+
588
+ # Used internally to update self's prediction values across a single
589
+ # recommendable type. Convenience method for {#update_recommendations}
590
+ #
591
+ # @param [Class] klass the recommendable type to update predictions for
592
+ # @private
593
+ def update_recommendations_for(klass)
594
+ klass.find_each do |object|
595
+ next if has_rated?(object) || !object.has_been_rated? || has_ignored?(object) || has_stashed?(object)
596
+ prediction = predict(object)
597
+ Recommendable.redis.zadd(predictions_set_for(object.class), prediction, object.redis_key) if prediction
598
+ end
599
+ end
600
+
601
+ # @private
602
+ def likes_set_for(klass)
603
+ "#{self.class}:#{id}:likes:#{klass}"
604
+ end
605
+
606
+ # @private
607
+ def dislikes_set_for(klass)
608
+ "#{self.class}:#{id}:dislikes:#{klass}"
609
+ end
610
+
611
+ # @private
612
+ def similarity_set
613
+ "#{self.class}:#{id}:similarities"
614
+ end
615
+
616
+ # @private
617
+ def predictions_set_for(klass)
618
+ "#{self.class}:#{id}:predictions:#{klass}"
619
+ end
620
+
621
+ # @private
622
+ def unpredict(object)
623
+ Recommendable.redis.zrem predictions_set_for(object.class), object.redis_key
624
+ end
625
+
626
+ # Used for setup purposes. Creates and populates sets in redis containing
627
+ # self's likes and dislikes.
628
+ # @private
629
+ def create_recommended_to_sets
630
+ Recommendable.recommendable_classes.each do |klass|
631
+ likes_for(klass).each {|like| Recommendable.redis.sadd likes_set_for(klass), like.likeable_id }
632
+ dislikes_for(klass).each {|dislike| Recommendable.redis.sadd dislikes_set_for(klass), dislike.dislikeable_id }
633
+ end
634
+ end
635
+
636
+ # Used for teardown purposes. Destroys the redis sets containing self's
637
+ # likes and dislikes, as they are only used during the process of
638
+ # updating recommendations and similarity values.
639
+ # @private
640
+ def destroy_recommended_to_sets
641
+ Recommendable.recommendable_classes.each do |klass|
642
+ Recommendable.redis.del likes_set_for(klass)
643
+ Recommendable.redis.del dislikes_set_for(klass)
644
+ end
645
+ end
646
+
647
+ protected :likes_set_for, :dislikes_set_for, :create_recommended_to_sets,
648
+ :destroy_recommended_to_sets
649
+
650
+ private :similarity_set, :unpredict, :predictions_set_for,
651
+ :update_recommendations_for, :update_recommendations,
652
+ :update_similarities, :similarity_with, :predict
653
+ end
654
+ end
655
+ end
656
+
657
+ def klassify(klass)
658
+ (klass.is_a?(String) || klass.is_a?(Symbol)) ? klass.to_s.camelize.constantize : klass
659
+ end