otwarchive-symphonyarchive/app/models/challenge_assignment.rb

499 lines
19 KiB
Ruby
Raw Permalink Normal View History

2026-03-11 22:22:11 +00:00
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