class Prompt < ApplicationRecord include TagTypeHelper # -1 represents all matching ALL = -1 # ASSOCIATIONS belongs_to :collection belongs_to :pseud has_one :user, through: :pseud belongs_to :challenge_signup, touch: true, inverse_of: :prompts belongs_to :tag_set, dependent: :destroy accepts_nested_attributes_for :tag_set has_many :tags, through: :tag_set belongs_to :optional_tag_set, class_name: "TagSet", dependent: :destroy accepts_nested_attributes_for :optional_tag_set has_many :optional_tags, through: :optional_tag_set, source: :tag has_many :request_claims, class_name: "ChallengeClaim", foreign_key: "request_prompt_id", inverse_of: :request_prompt, dependent: :destroy # SCOPES scope :claimed, -> { joins("INNER JOIN challenge_claims on prompts.id = challenge_claims.request_prompt_id") } scope :in_collection, lambda {|collection| where(collection_id: collection.id) } scope :with_tag, lambda { |tag| joins("JOIN set_taggings ON set_taggings.tag_set_id = prompts.tag_set_id"). where("set_taggings.tag_id = ?", tag.id) } # VALIDATIONS before_validation :inherit_from_signup, on: :create, if: :challenge_signup def inherit_from_signup self.pseud = challenge_signup.pseud self.collection = challenge_signup.collection end validates_presence_of :collection_id validates_presence_of :challenge_signup # based on the prompt restriction validates_presence_of :url, if: :url_required? validates_presence_of :description, if: :description_required? validates_presence_of :title, if: :title_required? delegate :url_required?, :description_required?, :title_required?, to: :prompt_restriction, allow_nil: true validates_length_of :description, maximum: ArchiveConfig.NOTES_MAX, too_long: ts("must be less than %{max} letters long.", max: ArchiveConfig.NOTES_MAX) validates_length_of :title, maximum: ArchiveConfig.TITLE_MAX, too_long: ts("must be less than %{max} letters long.", max: ArchiveConfig.TITLE_MAX) # i18n-tasks-use t("errors.attributes.url.invalid") validates :url, url_format: {allow_blank: true} # we validate the presence above, conditionally before_validation :cleanup_url def cleanup_url self.url = Addressable::URI.heuristic_parse(self.url) if self.url rescue Addressable::URI::InvalidURIError # url_format validation creates the error message end validate :correct_number_of_tags def correct_number_of_tags prompt_type = self.class.name restriction = prompt_restriction if restriction # make sure tagset has no more/less than the required/allowed number of tags of each type TagSet::TAG_TYPES.each do |tag_type| # get the tags of this type the user has specified taglist = tag_set ? eval("tag_set.#{tag_type}_taglist") : [] tag_count = taglist.count tag_label = tag_type_label_name(tag_type).downcase # check if user has chosen the "Any" option if self.send("any_#{tag_type}") if tag_count > 0 errors.add(:base, ts("^You have specified tags for %{tag_label} in your %{prompt_type} but also chose 'Any,' which will override them! Please only choose one or the other.", tag_label: tag_label, prompt_type: prompt_type)) end next end # otherwise let's make sure they offered the right number of tags required = eval("restriction.#{tag_type}_num_required") allowed = eval("restriction.#{tag_type}_num_allowed") unless tag_count.between?(required, allowed) taglist_string = taglist.empty? ? ts("none") : "(#{tag_count}) -- " + taglist.collect(&:name).join(ArchiveConfig.DELIMITER_FOR_OUTPUT) if allowed == 0 errors.add(:base, ts("^#{prompt_type}: Your #{prompt_type} cannot include any #{tag_label} tags, but you have included %{taglist}.", taglist: taglist_string)) elsif required == allowed errors.add(:base, ts("^#{prompt_type}: Your #{prompt_type} must include exactly %{required} #{tag_label} tags, but you have included #{tag_count} #{tag_label} tags in your current #{prompt_type}.", required: required)) else errors.add(:base, ts("^#{prompt_type}: Your #{prompt_type} must include between %{required} and %{allowed} #{tag_label} tags, but you have included #{tag_count} #{tag_label} tags in your current #{prompt_type}.", required: required, allowed: allowed)) end end end end end # make sure that if there is a specified set of allowed tags, the user's choices # are within that set, or otherwise canonical validate :allowed_tags def allowed_tags restriction = prompt_restriction return unless restriction && tag_set TagSet::TAG_TYPES.each do |tag_type| # if we have a specified set of tags of this type, make sure that all the # tags in the prompt are in the set. # skip the check, these will be tested in restricted_tags below next if TagSet::TAG_TYPES_RESTRICTED_TO_FANDOM.include?(tag_type) && restriction.send("#{tag_type}_restrict_to_fandom") taglist = tag_set.send("#{tag_type}_taglist") next if taglist.empty? if restriction.has_tags?(tag_type) disallowed_taglist = taglist - restriction.tags(tag_type) unless disallowed_taglist.empty? errors.add( :base, ts( "^These %{tag_label} tags in your %{prompt_type} are not allowed in this challenge: %{taglist}", tag_label: tag_type_label_name(tag_type).downcase, prompt_type: self.class.name.downcase, taglist: disallowed_taglist.collect(&:name).join(ArchiveConfig.DELIMITER_FOR_OUTPUT) ) ) end else noncanonical_taglist = taglist.reject(&:canonical) unless noncanonical_taglist.empty? errors.add( :base, ts( "^These %{tag_label} tags in your %{prompt_type} are not canonical and cannot be used in this challenge: %{taglist}. To fix this, please ask your challenge moderator to set up a tag set for the challenge. New tags can be added to the tag set manually by the moderator or through open nominations.", tag_label: tag_type_label_name(tag_type).downcase, prompt_type: self.class.name.downcase, taglist: noncanonical_taglist.collect(&:name).join(ArchiveConfig.DELIMITER_FOR_OUTPUT) ) ) end end end end # make sure that if any tags are restricted to fandom, the user's choices are # actually in the fandom they have chosen. validate :restricted_tags def restricted_tags restriction = prompt_restriction return unless restriction tag_set_associations = TagSetAssociation.where(owned_tag_set_id: restriction.owned_tag_sets.pluck(:id)) TagSet::TAG_TYPES_RESTRICTED_TO_FANDOM.each do |tag_type| next unless restriction.send("#{tag_type}_restrict_to_fandom") # tag_type is one of a set set so we know it is safe for constantize allowed_tags = tag_type.classify.constantize.with_parents(tag_set.fandom_taglist).canonical disallowed_taglist = tag_set ? tag_set.send("#{tag_type}_taglist") - allowed_tags : [] # check for tag set associations disallowed_taglist -= tag_set_associations .where(tag: disallowed_taglist, parent_tag_id: tag_set.fandom_taglist) .includes(:tag) .map(&:tag) next if disallowed_taglist.empty? errors.add(:base, :tags_not_in_fandom, prompt_type: self.class.name.downcase, tag_label: tag_type_label_name(tag_type).downcase, fandom: tag_set.fandom_taglist.pluck(:name).join(I18n.t("support.array.words_connector")), taglist: disallowed_taglist.pluck(:name).join(I18n.t("support.array.words_connector"))) end end # INSTANCE METHODS def can_delete? if challenge_signup && !challenge_signup.can_delete?(self) false else true end end def unfulfilled_claims self.request_claims.unfulfilled_in_collection(self.collection) end def fulfilled_claims self.request_claims.fulfilled end # Computes the "full" tag set (tag_set + optional_tag_set), and stores the # result as an instance variable for speed. This is used by the matching # algorithm, which doesn't change any signup/prompt/tagset information, so # it's okay to cache some information. (And if the info does change # mid-matching process, it's okay that we're using the tag sets that were # there when the moderator started the matching process.) def full_tag_set if @full_tag_set.nil? @full_tag_set = optional_tag_set ? tag_set + optional_tag_set : tag_set end @full_tag_set end # Returns true if there's a match, false otherwise. # self is the request, other is the offer def matches?(other, settings = nil) return nil if challenge_signup.id == other.challenge_signup.id return nil if settings.nil? TagSet::TAG_TYPES.each do |type| # We definitely match in this type if the request or the offer accepts # "any" for it. No need to check any more info for this type. next if send("any_#{type}") || other.send("any_#{type}") required_count = settings.send("num_required_#{type.pluralize}") match_count = if settings.send("include_optional_#{type.pluralize}") full_tag_set.match_rank(other.full_tag_set, type) else # we don't use optional tags to count towards required tag_set.match_rank(other.tag_set, type) end # if we have to match all and don't, not a match return false if required_count == ALL && match_count != ALL # we are a match only if we either match all or at least as many as required return false if match_count != ALL && match_count < required_count end true end # Count the number of overlapping tags of all types. Does not use ALL to # indicate a 100% match, since the goal is to give a bonus to matches where # both requester and offerer were specific about their desires, and had a lot # of overlap. def count_tags_matched(other) self_tags = full_tag_set.tags.map(&:id) other_tags = other.full_tag_set.tags.map(&:id) (self_tags & other_tags).size end def accepts_any?(type) send("any_#{type.downcase}") end def prompt_restriction raise "Base-type Prompt objects cannot have prompt restrictions. Try creating a Request or an Offer." end # tag groups def tag_groups self.tag_set ? self.tag_set.tags.group_by { |t| t.type.to_s } : {} end def claim_by(user) ChallengeClaim.where(request_prompt_id: self.id, claiming_user_id: user.id) end # checks if a prompt has been filled in a prompt meme def unfulfilled? if self.request_claims.empty? || !self.request_claims.fulfilled.exists? return true end end # currently only prompt meme prompts can be claimed, and by any number of people def claimable? if self.collection.challenge.is_a?(PromptMeme) true else false end end end