otwcode/otwarchive

View on GitHub
app/models/prompt.rb

Summary

Maintainability
D
1 day
Test Coverage
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)

  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
  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
    if restriction
      TagSet::TAG_TYPES_RESTRICTED_TO_FANDOM.each do |tag_type|
        if 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 ? eval("tag_set.#{tag_type}_taglist") - allowed_tags : []
          # check for tag set associations
          disallowed_taglist.reject! {|tag| TagSetAssociation.where(tag_id: tag.id, parent_tag_id: tag_set.fandom_taglist).exists?}
          unless disallowed_taglist.empty?
            errors.add(:base, ts("^These %{tag_label} tags in your %{prompt_type} are not in the selected fandom(s), %{fandom}: %{taglist} (Your moderator may be able to fix this.)",
                              prompt_type: self.class.name.downcase,
                              tag_label: tag_type_label_name(tag_type).downcase, fandom: tag_set.fandom_taglist.collect(&:name).join(ArchiveConfig.DELIMITER_FOR_OUTPUT),
                              taglist: disallowed_taglist.collect(&:name).join(ArchiveConfig.DELIMITER_FOR_OUTPUT)))
          end
        end
      end
    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