class ChallengeAssignment < ApplicationRecord # We use "-1" to represent all the requested items matching ALL = -1 belongs_to :collection belongs_to :offer_signup, class_name: "ChallengeSignup" belongs_to :request_signup, class_name: "ChallengeSignup" belongs_to :pinch_hitter, class_name: "Pseud" belongs_to :pinch_request_signup, class_name: "ChallengeSignup" # TODO: AO3-6851 Remove pinch_request_signup association from the challenge_assignments table belongs_to :creation, polymorphic: true # Make sure that the signups are an actual match if we're in the process of assigning # (post-sending, all potential matches have been deleted!) validate :signups_match, on: :update def signups_match if self.sent_at.nil? && self.request_signup.present? && self.offer_signup.present? && !self.request_signup.request_potential_matches.pluck(:offer_signup_id).include?(self.offer_signup_id) errors.add(:base, ts("does not match. Did you mean to write-in a giver?")) end end scope :for_request_signup, ->(signup) { where("request_signup_id = ?", signup.id) } scope :for_offer_signup, ->(signup) { where("offer_signup_id = ?", signup.id) } scope :in_collection, ->(collection) { where("challenge_assignments.collection_id = ?", collection.id) } scope :defaulted, -> { where.not(defaulted_at: nil) } scope :undefaulted, -> { where("defaulted_at IS NULL") } scope :uncovered, -> { where("covered_at IS NULL") } scope :covered, -> { where.not(covered_at: nil) } scope :sent, -> { where.not(sent_at: nil) } scope :with_pinch_hitter, -> { where.not(pinch_hitter_id: nil) } scope :with_offer, -> { where("offer_signup_id IS NOT NULL OR pinch_hitter_id IS NOT NULL") } scope :with_request, -> { where.not(request_signup_id: nil) } scope :with_no_request, -> { where("request_signup_id IS NULL") } scope :with_no_offer, -> { where("offer_signup_id IS NULL AND pinch_hitter_id IS NULL") } # sorting by request/offer REQUESTING_PSEUD_JOIN = "INNER JOIN challenge_signups ON challenge_assignments.request_signup_id = challenge_signups.id INNER JOIN pseuds ON challenge_signups.pseud_id = pseuds.id".freeze OFFERING_PSEUD_JOIN = "LEFT JOIN challenge_signups ON challenge_assignments.offer_signup_id = challenge_signups.id INNER JOIN pseuds ON (challenge_assignments.pinch_hitter_id = pseuds.id OR challenge_signups.pseud_id = pseuds.id)".freeze scope :order_by_requesting_pseud, -> { joins(REQUESTING_PSEUD_JOIN).order("pseuds.name") } scope :order_by_offering_pseud, -> { joins(OFFERING_PSEUD_JOIN).order("pseuds.name") } # Get all of a user's assignments scope :by_offering_user, lambda { |user| select("DISTINCT challenge_assignments.*") .joins(OFFERING_PSEUD_JOIN) .joins("INNER JOIN users ON pseuds.user_id = users.id") .where("users.id = ?", user.id) } # sorting by fulfilled/posted status COLLECTION_ITEMS_JOIN = "INNER JOIN collection_items ON (collection_items.collection_id = challenge_assignments.collection_id AND collection_items.item_id = challenge_assignments.creation_id AND collection_items.item_type = challenge_assignments.creation_type)" COLLECTION_ITEMS_LEFT_JOIN = "LEFT JOIN collection_items ON (collection_items.collection_id = challenge_assignments.collection_id AND collection_items.item_id = challenge_assignments.creation_id AND collection_items.item_type = challenge_assignments.creation_type)" WORKS_JOIN = "INNER JOIN works ON works.id = challenge_assignments.creation_id AND challenge_assignments.creation_type = 'Work'" WORKS_LEFT_JOIN = "LEFT JOIN works ON works.id = challenge_assignments.creation_id AND challenge_assignments.creation_type = 'Work'" scope :fulfilled, lambda { joins(COLLECTION_ITEMS_JOIN).joins(WORKS_JOIN) .where("challenge_assignments.creation_id IS NOT NULL AND collection_items.user_approval_status = ? AND collection_items.collection_approval_status = ? AND works.posted = 1", CollectionItem.user_approval_statuses[:approved], CollectionItem.collection_approval_statuses[:approved]) } scope :posted, -> { joins(WORKS_JOIN).where("challenge_assignments.creation_id IS NOT NULL AND works.posted = 1") } # should be faster than unfulfilled scope because no giant left joins def self.unfulfilled_in_collection(collection) fulfilled_ids = ChallengeAssignment.in_collection(collection).fulfilled.pluck(:id) fulfilled_ids.empty? ? in_collection(collection) : in_collection(collection).where.not(challenge_assignments: { id: fulfilled_ids }) end # faster than unposted scope because no left join! def self.unposted_in_collection(collection) posted_ids = ChallengeAssignment.in_collection(collection).posted.pluck(:id) posted_ids.empty? ? in_collection(collection) : in_collection(collection).where("'challenge_assignments.creation_id IS NULL OR challenge_assignments.id NOT IN (?)", posted_ids) end def self.duplicate_givers(collection) ids = in_collection(collection).group("challenge_assignments.offer_signup_id HAVING count(DISTINCT id) > 1").pluck(:offer_signup_id).compact ChallengeAssignment.where(offer_signup_id: ids) end def self.duplicate_recipients(collection) ids = in_collection(collection).group("challenge_assignments.request_signup_id HAVING count(DISTINCT id) > 1").pluck(:request_signup_id).compact ChallengeAssignment.where(request_signup_id: ids) end # has to be a left join to get assignments that don't have a collection item scope :unfulfilled, lambda { joins(COLLECTION_ITEMS_LEFT_JOIN).joins(WORKS_LEFT_JOIN) .where("challenge_assignments.creation_id IS NULL OR collection_items.user_approval_status != ? OR collection_items.collection_approval_status != ? OR works.posted = 0", CollectionItem.user_approval_statuses[:approved], CollectionItem.collection_approval_statuses[:approved]) } # ditto scope :unposted, -> { joins(WORKS_LEFT_JOIN).where("challenge_assignments.creation_id IS NULL OR works.posted = 0") } scope :unstarted, -> { where("challenge_assignments.creation_id IS NULL") } before_destroy :clear_assignment def clear_assignment if offer_signup offer_signup.assigned_as_offer = false offer_signup.save! end if request_signup request_signup.assigned_as_request = false request_signup.save! end end def get_collection_item return nil unless self.creation CollectionItem.where("collection_id = ? AND item_id = ? AND item_type = ?", self.collection_id, self.creation_id, self.creation_type).first end def started? !self.creation.nil? end def fulfilled? self.posted? && (item = get_collection_item) && item.approved? end def posted? self.creation && (creation.respond_to?(:posted?) ? creation.posted? : true) end def defaulted=(value) self.defaulted_at = (Time.now if value == "1") end def defaulted !self.defaulted_at.nil? end alias defaulted? defaulted def offer_signup_pseud=(pseud_byline) if pseud_byline.blank? self.offer_signup = nil else signup = signup_for_byline(pseud_byline) self.offer_signup = signup if signup end end def offer_signup_pseud self.offer_signup.try(:pseud).try(:byline) || "" end def request_signup_pseud=(pseud_byline) if pseud_byline.blank? self.request_signup = nil else signup = signup_for_byline(pseud_byline) self.request_signup = signup if signup end end def request_signup_pseud self.request_signup.try(:pseud).try(:byline) || "" end def signup_for_byline(byline) pseud = Pseud.parse_byline(byline) collection.signups.find_by(pseud: pseud) end def title "#{self.collection.title} (#{self.request_byline})" end def offering_user offering_pseud ? offering_pseud.user : nil end def offering_pseud offer_signup ? offer_signup.pseud : pinch_hitter end def requesting_pseud request_signup&.pseud end def offer_byline if offer_signup && offer_signup.pseud offer_signup.pseud.byline else (pinch_hitter ? I18n.t("challenge_assignment.offer_byline.pinch_hitter", pinch_hitter_byline: pinch_hitter.byline) : I18n.t("challenge_assignment.offer_byline.none")) end end def request_byline requesting_pseud&.byline || I18n.t("challenge_assignment.request_byline.none") end def pinch_hitter_byline pinch_hitter ? pinch_hitter.byline : "" end def pinch_hitter_byline=(byline) self.pinch_hitter = Pseud.parse_byline(byline) end def default self.defaulted_at = Time.now save end def cover(pseud) new_assignment = self.covered_at ? request_signup.request_assignments.last : ChallengeAssignment.new new_assignment.collection = self.collection new_assignment.request_signup_id = request_signup_id new_assignment.pinch_hitter = pseud new_assignment.sent_at = nil new_assignment.save! new_assignment.send_out self.covered_at = Time.now new_assignment.save && save end def send_out # don't resend! unless self.sent_at self.sent_at = Time.now save assigned_to = if self.offer_signup self.offer_signup.pseud.user else (self.pinch_hitter ? self.pinch_hitter.user : nil) end if assigned_to && self.request_signup I18n.with_locale(assigned_to.preference.locale_for_mails) do UserMailer.challenge_assignment_notification(collection.id, assigned_to.id, self.id).deliver_later end end end end @queue = :collection # This will be called by a worker when a job needs to be processed def self.perform(method, *args) self.send(method, *args) end # send assignments out to all participants def self.send_out(collection) Resque.enqueue(ChallengeAssignment, :delayed_send_out, collection.id) end def self.delayed_send_out(collection_id) collection = Collection.find(collection_id) # update the collection challenge with the time the assignments are sent challenge = collection.challenge challenge.assignments_sent_at = Time.now challenge.save # send out each assignment collection.assignments.each do |assignment| assignment.send_out end collection.notify_maintainers_assignments_sent # purge the potential matches! we don't want bazillions of them in our db PotentialMatch.clear!(collection) end # generate automatic match for a collection # this requires potential matches to already be generated def self.generate(collection) REDIS_GENERAL.set(progress_key(collection), 1) Resque.enqueue(ChallengeAssignment, :delayed_generate, collection.id) end def self.progress_key(collection) "challenge_assignment_in_progress_for_#{collection.id}" end def self.in_progress?(collection) REDIS_GENERAL.get(progress_key(collection)) ? true : false end def self.delayed_generate(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 regenerate assignments # after sending assignments, they can use the Purge Assignments button.) return end settings = collection.challenge.potential_match_settings REDIS_GENERAL.set(progress_key(collection), 1) ChallengeAssignment.clear!(collection) # we sort signups into buckets based on how many potential matches they have @request_match_buckets = {} @offer_match_buckets = {} @max_match_count = 0 if settings.nil? || settings.no_match_required? # stuff everyone into the same bucket @max_match_count = 1 @request_match_buckets[1] = collection.signups @offer_match_buckets[1] = collection.signups else collection.signups.find_each do |signup| next if signup.nil? request_match_count = signup.request_potential_matches.count @request_match_buckets[request_match_count] ||= [] @request_match_buckets[request_match_count] << signup @max_match_count = (request_match_count > @max_match_count ? request_match_count : @max_match_count) offer_match_count = signup.offer_potential_matches.count @offer_match_buckets[offer_match_count] ||= [] @offer_match_buckets[offer_match_count] << signup @max_match_count = (offer_match_count > @max_match_count ? offer_match_count : @max_match_count) end end # now that we have the buckets, we go through assigning people in order # of people with the fewest options first. # (if someone has no potential matches they get a placeholder assignment with no # matches.) 0.upto(@max_match_count) do |count| if @request_match_buckets[count] @request_match_buckets[count].sort_by { rand } .each do |request_signup| # go through the potential matches in order from best to worst and try and assign request_signup.reload next if request_signup.assigned_as_request ChallengeAssignment.assign_request!(collection, request_signup) end end next unless @offer_match_buckets[count] @offer_match_buckets[count].sort_by { rand } .each do |offer_signup| offer_signup.reload next if offer_signup.assigned_as_offer ChallengeAssignment.assign_offer!(collection, offer_signup) end end REDIS_GENERAL.del(progress_key(collection)) if collection.collection_email.present? UserMailer.potential_match_generation_notification(collection.id, collection.collection_email).deliver_later else collection.maintainers_list.each do |user| I18n.with_locale(user.preference.locale_for_mails) do UserMailer.potential_match_generation_notification(collection.id, user.email).deliver_later end end end end # go through the request's potential matches in order from best to worst and try and assign def self.assign_request!(collection, request_signup) assignment = ChallengeAssignment.new(collection: collection, request_signup: request_signup) last_choice = nil assigned = false request_signup.request_potential_matches.sort.reverse.each do |potential_match| # skip if this signup has already been assigned as an offer next if potential_match.offer_signup.assigned_as_offer # if there's a circular match let's save it as our last choice if potential_match.offer_signup.assigned_as_request && !last_choice && collection.assignments.for_request_signup(potential_match.offer_signup).first.offer_signup == request_signup last_choice = potential_match next end # otherwise let's use it assigned = ChallengeAssignment.do_assign_request!(assignment, potential_match) break end ChallengeAssignment.do_assign_request!(assignment, last_choice) if !assigned && last_choice request_signup.assigned_as_request = true request_signup.save! assignment.save! assignment end # go through the offer's potential matches in order from best to worst and try and assign def self.assign_offer!(collection, offer_signup) assignment = ChallengeAssignment.new(collection: collection, offer_signup: offer_signup) last_choice = nil assigned = false offer_signup.offer_potential_matches.sort.reverse.each do |potential_match| # skip if already assigned as a request next if potential_match.request_signup.assigned_as_request # if there's a circular match let's save it as our last choice if potential_match.request_signup.assigned_as_offer && !last_choice && collection.assignments.for_offer_signup(potential_match.request_signup).first.request_signup == offer_signup last_choice = potential_match next end # otherwise let's use it assigned = ChallengeAssignment.do_assign_offer!(assignment, potential_match) break end ChallengeAssignment.do_assign_offer!(assignment, last_choice) if !assigned && last_choice offer_signup.assigned_as_offer = true offer_signup.save! assignment.save! assignment end def self.do_assign_request!(assignment, potential_match) assignment.offer_signup = potential_match.offer_signup potential_match.offer_signup.assigned_as_offer = true potential_match.offer_signup.save! end def self.do_assign_offer!(assignment, potential_match) assignment.request_signup = potential_match.request_signup potential_match.request_signup.assigned_as_request = true potential_match.request_signup.save! end # clear out all previous assignments. # note: this does NOT invoke callbacks because ChallengeAssignments don't have any dependent=>destroy # or associations def self.clear!(collection) ChallengeAssignment.where(collection_id: collection.id).delete_all ChallengeSignup.where(collection_id: collection.id).update_all(assigned_as_offer: false, assigned_as_request: false) end # create placeholders for any assignments left empty # (this is for after manual updates have left some users without an # assignment) def self.update_placeholder_assignments!(collection) # delete any assignments that have neither an offer nor a request associated collection.assignments.each do |assignment| assignment.destroy if assignment.offer_signup.blank? && assignment.request_signup.blank? end collection.signups.each do |signup| # if this signup has at least one giver now, get rid of any leftover placeholders if signup.request_assignments.count > 1 signup.request_assignments.each do |assignment| assignment.destroy if assignment.offer_signup.blank? end end # if this signup has at least one recipient now, get rid of any leftover placeholders if signup.offer_assignments.count > 1 signup.offer_assignments.each do |assignment| assignment.destroy if assignment.request_signup.blank? end end # if this signup doesn't have any giver now, create a placeholder if signup.request_assignments.empty? assignment = ChallengeAssignment.new(collection: collection, request_signup: signup) assignment.save end # if this signup doesn't have any recipient now, create a placeholder if signup.offer_assignments.empty? assignment = ChallengeAssignment.new(collection: collection, offer_signup: signup) assignment.save end end end end