class PotentialMatch < ApplicationRecord # We use "-1" to represent all the requested items matching ALL = -1 CACHE_PROGRESS_KEY = "potential_match_status_for_".freeze CACHE_SIGNUP_KEY = "potential_match_signups_for_".freeze CACHE_INTERRUPT_KEY = "potential_match_interrupt_for_".freeze CACHE_INVALID_SIGNUP_KEY = "potential_match_invalid_signup_for_".freeze belongs_to :collection belongs_to :offer_signup, class_name: "ChallengeSignup" belongs_to :request_signup, class_name: "ChallengeSignup" def self.progress_key(collection) CACHE_PROGRESS_KEY + collection.id.to_s end def self.signup_key(collection) CACHE_SIGNUP_KEY + collection.id.to_s end def self.interrupt_key(collection) CACHE_INTERRUPT_KEY + collection.id.to_s end def self.invalid_signup_key(collection) CACHE_INVALID_SIGNUP_KEY + collection.id.to_s end def self.clear!(collection) # rapidly delete all potential prompt matches and potential matches # WITHOUT CALLBACKS pmids = collection.potential_matches.pluck(:id) PotentialMatch.where(id: pmids).delete_all end def self.set_up_generating(collection) REDIS_GENERAL.set progress_key(collection), "0.0" end def self.cancel_generation(collection) REDIS_GENERAL.set interrupt_key(collection), "1" end def self.canceled?(collection) REDIS_GENERAL.get(interrupt_key(collection)) == "1" end @queue = :collection # This only works on class methods def self.perform(method, *args) self.send(method, *args) end def self.generate(collection) Resque.enqueue(self, :generate_in_background, collection.id) end # Regenerate the potential matches for a given signup def self.regenerate_for_signup(signup) Resque.enqueue(self, :regenerate_for_signup_in_background, signup.id) end def self.invalid_signups_for(collection) REDIS_GENERAL.smembers(invalid_signup_key(collection)) end def self.clear_invalid_signups(collection) REDIS_GENERAL.del invalid_signup_key(collection) end # The actual method that generates the potential matches for an entire collection def self.generate_in_background(collection_id) collection = Collection.find(collection_id) if collection.challenge.assignments_sent_at.present? # If assignments have been sent, we don't want to delete everything and # regenerate. (If the challenge moderator wants to recalculate potential # matches after sending assignments, they can use the Purge Assignments # button.) return end # check for invalid signups PotentialMatch.clear_invalid_signups(collection) invalid_signup_ids = collection.signups.reject(&:valid?) .collect(&:id) if invalid_signup_ids.present? invalid_signup_ids.each { |sid| REDIS_GENERAL.sadd invalid_signup_key(collection), sid } if collection.collection_email.present? UserMailer.invalid_signup_notification(collection.id, invalid_signup_ids, collection.collection_email).deliver_later else collection.maintainers_list.each do |user| I18n.with_locale(user.preference.locale_for_mails) do UserMailer.invalid_signup_notification(collection.id, invalid_signup_ids, user.email).deliver_later end end end PotentialMatch.cancel_generation(collection) else PotentialMatch.clear!(collection) settings = collection.challenge.potential_match_settings if settings.no_match_required? matcher = PotentialMatcherUnconstrained.new(collection) else index_type = PromptTagTypeInfo.new(collection).good_index_types.first matcher = PotentialMatcherConstrained.new(collection, index_type) end matcher.generate end # TODO: for any signups with no potential matches try regenerating? PotentialMatch.finish_generation(collection) end # Generate potential matches for a single signup. def self.generate_for_signup(collection, signup, settings, collection_tag_sets, required_types, prompt_type = "request") # only check the signups that have any overlap match_signup_ids = PotentialMatch.matching_signup_ids(collection, signup, collection_tag_sets, required_types, prompt_type) # We randomize the signup ids to make sure potential matches are distributed across all the participants match_signup_ids.shuffle.each do |other_signup_id| next if signup.id == other_signup_id # The "match" method of ChallengeSignup creates and returns a new # (unsaved) potential match object. It assumes the signup that is calling # is the requesting signup, so if this is meant to be an offering signup # instead, we call it from the other signup. if prompt_type == "request" other_signup = ChallengeSignup.with_offer_tags.find(other_signup_id) potential_match = signup.match(other_signup, settings) else other_signup = ChallengeSignup.with_request_tags.find(other_signup_id) potential_match = other_signup.match(signup, settings) end potential_match.save if potential_match&.valid? end end # Get the ids of all signups that have some overlap in the tag types required for matching def self.matching_signup_ids(collection, signup, collection_tag_sets, required_types, prompt_type = "request") matching_signup_ids = [] if required_types.empty? # nothing is required, so any signup can match -- check all of them return collection.signups.pluck(:id) end # get the tagsets used in the signup we are trying to match signup_tagsets = signup.send(prompt_type.pluralize).pluck(:tag_set_id, :optional_tag_set_id).flatten.compact # get the ids of all the tags of the required types in the signup's tagsets signup_tags = SetTagging.where(tag_set_id: signup_tagsets).joins(:tag).where(tags: { type: required_types }).pluck(:tag_id) if signup_tags.empty? # a match is required by the settings but the user hasn't put any of the required tags in, meaning they are open to anything return collection.signups.pluck(:id) else # now find all the tagsets in the collection that share the original signup's tags match_tagsets = SetTagging.where(tag_id: signup_tags, tag_set_id: collection_tag_sets).pluck(:tag_set_id).uniq # and now we look up any signups that have one of those tagsets in the opposite position -- ie, # if this signup is a request, we are looking for offers with the same tag; if it's an offer, we're # looking for requests with the same tag. matching_signup_ids = (prompt_type == "request" ? Offer : Request) .where("tag_set_id IN (?) OR optional_tag_set_id IN (?)", match_tagsets, match_tagsets) .pluck(:challenge_signup_id).compact # now add on "any" matches for the required types condition = case required_types.first.underscore when "fandom" "any_fandom = 1" when "character" "any_character = 1" when "rating" "any_rating = 1" when "relationship" "any_relationship = 1" when "category" "any_category = 1" when "archive_warning" "any_archive_warning = 1" when "freeform" "any_freeform = 1" else " 1 = 0" end matching_signup_ids += collection.prompts.where(condition).pluck(:challenge_signup_id) end matching_signup_ids.uniq end # Regenerate potential matches for a single signup within a challenge where (presumably) # the other signups already have matches generated. # To do this, we have to regenerate its potential matches both as a request and as an offer # (instead of just generating them as a request as we do when generating ALL potential matches) def self.regenerate_for_signup_in_background(signup_id) # The signup will be acting as both offer and request, so we want to load # both request tags and offer tags. signup = ChallengeSignup.with_request_tags.with_offer_tags.find(signup_id) collection = signup.collection # Get all the data settings = collection.challenge.potential_match_settings collection_tag_sets = Prompt.where(collection_id: collection.id).pluck(:tag_set_id, :optional_tag_set_id).flatten.compact required_types = settings.required_types.map(&:classify) # clear the existing potential matches for this signup in each direction signup.offer_potential_matches.destroy_all signup.request_potential_matches.destroy_all # We check the signup in both directions -- as a request signup and as an offer signup %w[request offer].each do |prompt_type| PotentialMatch.generate_for_signup(collection, signup, settings, collection_tag_sets, required_types, prompt_type) end end # Finish off the potential match generation def self.finish_generation(collection) REDIS_GENERAL.del progress_key(collection) REDIS_GENERAL.del signup_key(collection) if PotentialMatch.canceled?(collection) REDIS_GENERAL.del interrupt_key(collection) # eventually we'll want to be able to pick up where we left off, # but not there yet PotentialMatch.clear!(collection) else ChallengeAssignment.delayed_generate(collection.id) end end def self.in_progress?(collection) if REDIS_GENERAL.get(progress_key(collection)) if PotentialMatch.canceled?(collection) self.finish_generation(collection) return false end return true end false end # The PotentialMatcherProgress class calculates the percent, so we just need # to retrieve it from redis. def self.progress(collection) REDIS_GENERAL.get(progress_key(collection)) end # sorting routine -- this gets used to rank the relative goodness of potential matches include Comparable def <=>(other) return 0 if self.id == other.id # start with seeing how many offers/requests match cmp = compare_all(self.num_prompts_matched, other.num_prompts_matched) return cmp unless cmp.zero? # compare the "quality" of the best prompt match # (i.e. the number of matching tags between the most closely-matching # request prompt/offer prompt pair) cmp = compare_all(max_tags_matched, other.max_tags_matched) return cmp unless cmp.zero? # if we're a match down to here just match on id self.id <=> other.id end protected def compare_all(self_value, other_value) if self_value == ALL other_value == ALL ? 0 : 1 else (other_value == ALL ? -1 : self_value <=> other_value) end end end