recommendable 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
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