app/models/profile_suggestion.rb
class ProfileSuggestion < ApplicationRecord
belongs_to :person, optional: true
belongs_to :suggestion, class_name: "Profile", foreign_key: :suggestion_id, optional: true
attr_accessible :person, :suggestion, :suggestion_type, :categories, :enabled
has_many :suggestion_connections, foreign_key: "suggestion_id"
has_many :profile_connections, through: :suggestion_connections, source: :connection, source_type: "Profile"
has_many :tag_connections, through: :suggestion_connections, source: :connection, source_type: "ActsAsTaggableOn::Tag"
before_create do |profile_suggestion|
profile_suggestion.suggestion_type = self.suggestion.class.to_s
end
after_destroy do |profile_suggestion|
self.class.generate_profile_suggestions(profile_suggestion.person)
end
extend ActsAsHavingSettings::ClassMethods
acts_as_having_settings field: :categories
validate :must_be_a_valid_category, on: :create
def must_be_a_valid_category
if categories.keys.map { |cat| self.respond_to?(cat) }.include?(false)
errors.add(:categories, "Category must be valid")
end
end
validates_uniqueness_of :suggestion_id, scope: [:person_id]
scope :of_person, -> { where suggestion_type: "Person" }
scope :of_community, -> { where suggestion_type: "Community" }
scope :enabled, -> { where enabled: true }
# {:category_type => ['category-icon', 'category-label']}
CATEGORIES = {
people_with_common_friends: ["menu-people", _("Friends in common")],
people_with_common_communities: ["menu-community", _("Communities in common")],
people_with_common_tags: ["edit", _("Tags in common")],
communities_with_common_friends: ["menu-people", _("Friends in common")],
communities_with_common_tags: ["edit", _("Tags in common")]
}
def category_icon(category)
"icon-" + ProfileSuggestion::CATEGORIES[category][0]
end
def category_label(category)
ProfileSuggestion::CATEGORIES[category][1]
end
RULES = {
people_with_common_communities: {
threshold: 2, weight: 1, connection: "Profile"
},
people_with_common_friends: {
threshold: 2, weight: 1, connection: "Profile"
},
people_with_common_tags: {
threshold: 2, weight: 1, connection: "Tag"
},
communities_with_common_friends: {
threshold: 2, weight: 1, connection: "Profile"
},
communities_with_common_tags: {
threshold: 2, weight: 1, connection: "Tag"
}
}
RULES.keys.each do |rule|
settings_items rule
attr_accessible rule
end
# Number of suggestions by rule
N_SUGGESTIONS = 30
# Minimum number of suggestions
MIN_LIMIT = 10
def self.profile_id(rule)
"#{rule}_profile_id"
end
def self.connections(rule)
"#{rule}_connections"
end
def self.counter(rule)
"#{rule}_count"
end
# If you are about to rewrite the following sql queries, think twice. After
# that make sure that whatever you are writing to replace it should be faster
# than how it is now. Yes, sqls are ugly but are fast! And fast is what we
# need here.
#
# The logic behind this code is to produce a table somewhat like this:
# profile_id | rule1_count | rule1_connections | rule2_count | rule2_connections | ... | score |
# 12 | 2 | {32,54} | 3 | {8,22,27} | ... | 13 |
# 13 | 4 | {3,12,32,54} | 2 | {11,24} | ... | 15 |
# 14 | | | 2 | {44,56} | ... | 17 |
# ...
# ...
#
# This table has the suggested profile id and the count and connections of
# each rule that made this profile be suggested. Each suggestion has a score
# associated based on the rules' counts and rules' weights.
#
# From this table, we can sort suggestions by the score and save a small
# amount of them in the database. At this moment we also register the
# connections of each suggestion.
def self.calculate_suggestions(person)
suggested_profiles = all_suggestions(person)
return if suggested_profiles.nil?
# Filter communities that person has already join and people that are already his friends.
suggested_profiles = suggested_profiles.where.not(id: person.memberships.map { |p| p.id } | person.friends.map { |f| f.id })
already_suggested_profiles = person.suggested_profiles
suggested_profiles = suggested_profiles.where.not(id: already_suggested_profiles)
# TODO suggested_profiles = suggested_profiles.order('score DESC')
suggested_profiles = suggested_profiles.limit(N_SUGGESTIONS)
return if suggested_profiles.blank?
suggested_profiles.each do |suggested_profile|
suggestion = person.suggested_profiles.find_by suggestion_id: suggested_profile.id
suggestion ||= person.suggested_profiles.build({ suggestion_id: suggested_profile.id }, { without_protection: true })
RULES.each do |rule, options|
begin
value = suggested_profile.send("#{rule}_count").to_i
rescue NoMethodError
next
end
connections = suggested_profile.send("#{rule}_connections") || []
connections = connections[1..-2] if connections.present?
connections.each do |connection_id|
next if SuggestionConnection.where(suggestion_id: suggestion.id, connection_id: connection_id, connection_type: options[:connection]).present?
SuggestionConnection.create!(suggestion: suggestion, connection_id: connection_id, connection_type: options[:connection])
end
suggestion.send("#{rule}=", value)
suggestion.score += value * options[:weight]
end
suggestion.save!
end
end
def self.people_with_common_friends(person)
person_friends = person.friends.map(&:id)
rule = "people_with_common_friends"
return if person_friends.blank?
"SELECT person_id as #{profile_id(rule)},
array_agg(friend_id) as #{connections(rule)},
count(person_id) as #{counter(rule)}
FROM friendships WHERE friend_id IN (#{person_friends.join(',')})
AND person_id NOT IN (#{(person_friends << person.id).join(',')})
GROUP BY person_id"
end
def self.people_with_common_communities(person)
person_communities = person.communities.map(&:id)
rule = "people_with_common_communities"
return if person_communities.blank?
"SELECT common_members.accessor_id as #{profile_id(rule)},
array_agg(common_members.resource_id) as #{connections(rule)},
count(common_members.accessor_id) as #{counter(rule)}
FROM
(SELECT DISTINCT accessor_id, resource_id FROM
role_assignments WHERE role_assignments.resource_id IN (#{person_communities.join(',')}) AND
role_assignments.accessor_id != #{person.id} AND role_assignments.resource_type = 'Profile' AND
role_assignments.accessor_type = 'Profile') AS common_members
GROUP BY common_members.accessor_id"
end
def self.people_with_common_tags(person)
profile_tags = person.articles.select("tags.id").joins(:tags).map(&:id)
rule = "people_with_common_tags"
return if profile_tags.blank?
"SELECT results.profiles_id as #{profile_id(rule)},
array_agg(results.tags_id) as #{connections(rule)},
count(results.profiles_id) as #{counter(rule)}
FROM (
SELECT DISTINCT tags.id as tags_id, profiles.id as profiles_id FROM profiles
INNER JOIN articles ON articles.profile_id = profiles.id
INNER JOIN taggings ON taggings.taggable_id = articles.id AND taggings.context = ('tags') AND taggings.taggable_type = 'Article'
INNER JOIN tags ON tags.id = taggings.tag_id
WHERE (tags.id in (#{profile_tags.join(',')}) AND profiles.id != #{person.id})) AS results
GROUP BY results.profiles_id"
end
def self.communities_with_common_friends(person)
person_friends = person.friends.map(&:id)
rule = "communities_with_common_friends"
return if person_friends.blank?
"SELECT common_communities.resource_id as #{profile_id(rule)},
array_agg(common_communities.accessor_id) as #{connections(rule)},
count(common_communities.resource_id) as #{counter(rule)}
FROM
(SELECT DISTINCT accessor_id, resource_id FROM
role_assignments WHERE role_assignments.accessor_id IN (#{person_friends.join(',')}) AND
role_assignments.accessor_id != #{person.id} AND role_assignments.resource_type = 'Profile' AND
role_assignments.accessor_type = 'Profile') AS common_communities
GROUP BY common_communities.resource_id"
end
def self.communities_with_common_tags(person)
profile_tags = person.articles.select("tags.id").joins(:tags).map(&:id)
rule = "communities_with_common_tags"
return if profile_tags.blank?
"SELECT results.profiles_id as #{profile_id(rule)},
array_agg(results.tags_id) as #{connections(rule)},
count(results.profiles_id) as #{counter(rule)}
FROM
(SELECT DISTINCT tags.id as tags_id, profiles.id AS profiles_id FROM profiles
INNER JOIN articles ON articles.profile_id = profiles.id
INNER JOIN taggings ON taggings.taggable_id = articles.id AND taggings.context = ('tags') AND taggings.taggable_type = 'Article'
INNER JOIN tags ON tags.id = taggings.tag_id
WHERE (tags.id IN (#{profile_tags.join(',')}) AND profiles.id != #{person.id})) AS results
GROUP BY results.profiles_id"
end
def self.all_suggestions(person)
select_string = ["profiles.*"]
suggestions_join = []
where_string = []
valid_rules = []
previous_rule = nil
join_column = nil
RULES.each do |rule, options|
rule_select = self.send(rule, person)
next if !rule_select.present?
valid_rules << rule
select_string << "suggestions.#{counter(rule)} as #{counter(rule)}, suggestions.#{connections(rule)} as #{connections(rule)}"
where_string << "#{counter(rule)} >= #{options[:threshold]}"
rule_select = "
(SELECT profiles.id as #{profile_id(rule)},
#{rule}_sub.#{counter(rule)} as #{counter(rule)},
#{rule}_sub.#{connections(rule)} as #{connections(rule)}
FROM profiles
LEFT OUTER JOIN (#{rule_select}) as #{rule}_sub
ON profiles.id = #{rule}_sub.#{profile_id(rule)}) AS #{rule}"
if previous_rule.nil?
result = rule_select
else
result = "INNER JOIN #{rule_select}
ON #{previous_rule}.#{profile_id(previous_rule)} = #{rule}.#{profile_id(rule)}"
end
previous_rule = rule
suggestions_join << result
end
return if valid_rules.blank?
select_string = select_string.compact.join(",")
join_string = "INNER JOIN (SELECT * FROM #{suggestions_join.compact.join(' ')}) AS suggestions ON profiles.id = suggestions.#{profile_id(valid_rules.first)}"
where_string = where_string.compact.join(" OR ")
person.environment.profiles
.select(select_string)
.joins(join_string)
.where(where_string)
end
def disable
self.enabled = false
self.save!
self.class.generate_profile_suggestions(self.person)
end
def self.generate_all_profile_suggestions
Delayed::Job.enqueue(ProfileSuggestion::GenerateAllJob.new) unless ProfileSuggestion::GenerateAllJob.exists?
end
def self.generate_profile_suggestions(person, force = false)
return if person.suggested_profiles.enabled.count >= MIN_LIMIT && !force
Delayed::Job.enqueue ProfileSuggestionsJob.new(person.id) unless ProfileSuggestionsJob.exists?(person.id)
end
class GenerateAllJob
def self.exists?
Delayed::Job.by_handler("--- !ruby/object:ProfileSuggestion::GenerateAllJob {}\n").count > 0
end
def perform
Person.find_each { |person| ProfileSuggestion.generate_profile_suggestions(person) }
end
end
end