285 lines
11 KiB
Ruby
285 lines
11 KiB
Ruby
|
|
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
|