Noosfero/noosfero

View on GitHub
app/models/profile_suggestion.rb

Summary

Maintainability
C
1 day
Test Coverage
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