498 lines
19 KiB
Ruby
Executable file
498 lines
19 KiB
Ruby
Executable file
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
|