class Pseud < ApplicationRecord include Searchable include WorksOwner include Justifiable include AfterCommitEverywhere has_one_attached :icon do |attachable| attachable.variant(:standard, resize_to_limit: [100, 100], loader: { n: -1 }) end # i18n-tasks-use t("errors.attributes.icon.invalid_format") # i18n-tasks-use t("errors.attributes.icon.too_large") validates :icon, attachment: { allowed_formats: %w[image/gif image/jpeg image/png], maximum_size: ArchiveConfig.ICON_SIZE_KB_MAX.kilobytes } NAME_LENGTH_MIN = 1 NAME_LENGTH_MAX = 40 DESCRIPTION_MAX = 500 belongs_to :user delegate :login, to: :user, prefix: true, allow_nil: true alias user_name user_login has_many :bookmarks, dependent: :destroy has_many :recs, -> { where(rec: true) }, class_name: 'Bookmark' has_many :comments has_many :creatorships, dependent: :destroy has_many :approved_creatorships, -> { Creatorship.approved }, class_name: "Creatorship" has_many :works, through: :approved_creatorships, source: :creation, source_type: "Work" has_many :chapters, through: :approved_creatorships, source: :creation, source_type: "Chapter" has_many :series, through: :approved_creatorships, source: :creation, source_type: "Series" has_many :tags, through: :works has_many :filters, through: :works has_many :direct_filters, through: :works has_many :collection_participants, dependent: :destroy has_many :collections, through: :collection_participants has_many :tag_set_ownerships, dependent: :destroy has_many :tag_sets, through: :tag_set_ownerships has_many :challenge_signups, dependent: :destroy has_many :gifts, -> { where(rejected: false) }, inverse_of: :pseud, dependent: :destroy has_many :gift_works, through: :gifts, source: :work has_many :rejected_gifts, -> { where(rejected: true) }, class_name: "Gift", inverse_of: :pseud, dependent: :destroy has_many :rejected_gift_works, through: :rejected_gifts, source: :work has_many :offer_assignments, -> { where("challenge_assignments.sent_at IS NOT NULL") }, through: :challenge_signups has_many :pinch_hit_assignments, -> { where("challenge_assignments.sent_at IS NOT NULL") }, class_name: "ChallengeAssignment", foreign_key: "pinch_hitter_id" has_many :prompts, dependent: :destroy before_validation :clear_icon validates_presence_of :name validates_length_of :name, within: NAME_LENGTH_MIN..NAME_LENGTH_MAX, too_short: ts("is too short (minimum is %{min} characters)", min: NAME_LENGTH_MIN), too_long: ts("is too long (maximum is %{max} characters)", max: NAME_LENGTH_MAX) validates :name, uniqueness: { scope: :user_id } validates_format_of :name, message: ts('can contain letters, numbers, spaces, underscores, and dashes.'), with: /\A[\p{Word} -]+\Z/u validates_format_of :name, message: ts('must contain at least one letter or number.'), with: /\p{Alnum}/u validates_length_of :description, allow_blank: true, maximum: DESCRIPTION_MAX, too_long: ts("must be less than %{max} characters long.", max: DESCRIPTION_MAX) validates_length_of :icon_alt_text, allow_blank: true, maximum: ArchiveConfig.ICON_ALT_MAX, too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.ICON_ALT_MAX) validates_length_of :icon_comment_text, allow_blank: true, maximum: ArchiveConfig.ICON_COMMENT_MAX, too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.ICON_COMMENT_MAX) after_create :reindex_user after_update :check_default_pseud after_update :expire_caches after_update :reindex_user, if: :name_changed? after_destroy :reindex_user after_commit :reindex_creations, :touch_comments scope :alphabetical, -> { order(:name) } scope :default_alphabetical, -> { order(is_default: :desc).alphabetical } scope :abbreviated_list, -> { default_alphabetical.limit(ArchiveConfig.ITEMS_PER_PAGE) } scope :for_search, -> { includes(:user).with_attached_icon } def self.not_orphaned where("user_id != ?", User.orphan_account) end # Enigel Dec 12 08: added sort method # sorting by pseud name or by login name in case of equality def <=>(other) (self.name.downcase <=> other.name.downcase) == 0 ? (self.user_name.downcase <=> other.user_name.downcase) : (self.name.downcase <=> other.name.downcase) end def to_param name end scope :public_work_count_for, -> (pseud_ids) { select('pseuds.id, count(pseuds.id) AS work_count') .joins(:works) .where( pseuds: { id: pseud_ids }, works: { posted: true, hidden_by_admin: false, restricted: false } ).group('pseuds.id') } scope :posted_work_count_for, -> (pseud_ids) { select('pseuds.id, count(pseuds.id) AS work_count') .joins(:works) .where( pseuds: { id: pseud_ids }, works: { posted: true, hidden_by_admin: false } ).group('pseuds.id') } scope :public_rec_count_for, -> (pseud_ids) { select('pseuds.id, count(pseuds.id) AS rec_count') .joins(:bookmarks) .where( pseuds: { id: pseud_ids }, bookmarks: { private: false, hidden_by_admin: false, rec: true } ) .group('pseuds.id') } def self.rec_counts_for_pseuds(pseuds) if pseuds.blank? {} else pseuds_with_counts = Pseud.public_rec_count_for(pseuds.collect(&:id)) count_hash = {} pseuds_with_counts.each {|p| count_hash[p.id] = p.rec_count.to_i} count_hash end end def self.work_counts_for_pseuds(pseuds) if pseuds.blank? {} else if User.current_user.nil? pseuds_with_counts = Pseud.public_work_count_for(pseuds.collect(&:id)) else pseuds_with_counts = Pseud.posted_work_count_for(pseuds.collect(&:id)) end count_hash = {} pseuds_with_counts.each {|p| count_hash[p.id] = p.work_count.to_i} count_hash end end def unposted_works @unposted_works = self.works.where(posted: false).order(created_at: :desc) end # Produces a byline that indicates the user's name if pseud is not unique def byline (name != user_name) ? "#{name} (#{user_name})" : name end # get the former byline def byline_was past_name = name_was.blank? ? name : name_was # if we have a user and their login has changed get the old one past_user_name = user.blank? ? "" : (user.login_was.blank? ? user.login : user.login_was) (past_name != past_user_name) ? "#{past_name} (#{past_user_name})" : past_name end # Parse a string of the "pseud.name (user.login)" format into an array # [pseud.name, user.login]. If there is no parenthesized login after the # pseud name, returns [pseud.name, nil]. def self.split_byline(byline) pseud_name, login = byline.split("(", 2) [pseud_name&.strip, login&.strip&.delete_suffix(")")] end # Parse a string of the "pseud.name (user.login)" format into a pseud. If the # form is just "pseud.name" with no parenthesized login, assumes that # pseud.name = user.login and goes from there. def self.parse_byline(byline) pseud_name, login = split_byline(byline) login ||= pseud_name Pseud.joins(:user).find_by(pseuds: { name: pseud_name }, users: { login: login }) end # Parse a string of the "pseud.name (user.login)" format into a list of # pseuds. Usually this will be just one pseud, but if the byline is of the # form "pseud.name" with no parenthesized username, it'll look for any pseud # with that name. def self.parse_byline_ambiguous(byline) pseud_name, login = split_byline(byline) if login Pseud.joins(:user).where(pseuds: { name: pseud_name }, users: { login: login }) else Pseud.where(name: pseud_name) end end # Takes a comma-separated list of bylines # Returns a hash containing an array of pseuds and an array of bylines that couldn't be found def self.parse_bylines(bylines) valid_pseuds = [] failures = [] banned_pseuds = [] bylines.split(",").each do |byline| pseud = parse_byline(byline) if pseud.nil? failures << byline.strip elsif pseud.user.banned? || pseud.user.suspended? banned_pseuds << pseud else valid_pseuds << pseud end end { pseuds: valid_pseuds.flatten.uniq, invalid_pseuds: failures, banned_pseuds: banned_pseuds.flatten.uniq.map(&:byline) } end ## AUTOCOMPLETE # set up autocomplete and override some methods include AutocompleteSource def autocomplete_prefixes [ "autocomplete_pseud" ] end def autocomplete_value "#{id}#{AUTOCOMPLETE_DELIMITER}#{byline}" end # This method is for use in before_* callbacks def autocomplete_value_was "#{id}#{AUTOCOMPLETE_DELIMITER}#{byline_was}" end # See byline_before_last_save for the reasoning behind why both this and # autocomplete_value_was exist in this model # # This method is for use in after_* callbacks def autocomplete_value_before_last_save "#{id}#{AUTOCOMPLETE_DELIMITER}#{byline_before_last_save}" end def byline_before_last_save past_name = name_before_last_save.blank? ? name : name_before_last_save # In this case, self belongs to a user that has already been saved # during it's (self's) callback cycle, which means we need to # look *back* at the user's [attributes]_before_last_save, since # [attribute]_was for the pseud's user will behave as if this were an # after_* callback on the user, instead of a before_* callback on self. # # see psued_sweeper.rb:13 for more context # past_user_name = user.blank? ? "" : (user.login_before_last_save.blank? ? user.login : user.login_before_last_save) (past_name != past_user_name) ? "#{past_name} (#{past_user_name})" : past_name end # This method is for removing stale autocomplete records in a before_* # callback, such as the one used in PseudSweeper # # This is a particular case for the Pseud model def remove_stale_from_autocomplete_before_save self.class.remove_from_autocomplete(self.autocomplete_search_string_was, self.autocomplete_prefixes, self.autocomplete_value_was) end ## END AUTOCOMPLETE def replace_me_with_default replacement = user.default_pseud # We don't use change_ownership here because we want to transfer both # approved and unapproved creatorships. self.creatorships.includes(:creation).each do |creatorship| next if creatorship.creation.nil? existing = replacement.creatorships.find_by(creation: creatorship.creation) if existing existing.update(approved: existing.approved || creatorship.approved) else creatorship.update(pseud: replacement) end end # Update the pseud ID for all comments. Also updates the timestamp, so that # the cache is invalidated and the pseud change will be visible. Comment.where(pseud_id: self.id).update_all(pseud_id: replacement.id, updated_at: Time.now) change_collections_membership change_gift_recipients change_challenge_participation self.destroy end # Change the ownership of a creation from one pseud to another def change_ownership(creation, pseud, options={}) transaction do # Update children before updating the creation itself, since deleting # creatorships from the creation will also delete them from the creation's # children. unless options[:skip_children] children = if creation.is_a?(Work) creation.chapters elsif creation.is_a?(Series) creation.works else [] end children.each do |child| change_ownership(child, pseud, options) end end # Should only add new creatorships if we're an approved co-creator. if creation.creatorships.approved.where(pseud: self).exists? creation.creatorships.find_or_create_by(pseud: pseud) end # But we should delete all creatorships, even invited ones: creation.creatorships.where(pseud: self).destroy_all if creation.is_a?(Work) creation.series.each do |series| if series.work_pseuds.where(id: id).exists? series.creatorships.find_or_create_by(pseud: pseud) else change_ownership(series, pseud, options.merge(skip_children: true)) end end comments = creation.total_comments.where("comments.pseud_id = ?", self.id) comments.each do |comment| comment.update_attribute(:pseud_id, pseud.id) end end # make sure changes affect caching/search/author fields creation.save end end def change_membership(collection, new_pseud) self.collection_participants.in_collection(collection).each do |cparticipant| cparticipant.pseud = new_pseud cparticipant.save end end def change_challenge_participation # We want to update all prompts associated with this pseud, but although # each prompt contains a pseud_id column, they're not indexed on it. That # means doing the search Prompt.where(pseud_id: self.id) would require # searching all rows of the prompts table. So instead, we do a join on the # challenge_signups table and look up prompts whose ChallengeSignup has the # pseud_id that we want to change. Prompt.joins(:challenge_signup). where("challenge_signups.pseud_id = #{id}"). update_all("prompts.pseud_id = #{user.default_pseud.id}") ChallengeSignup.where("pseud_id = #{self.id}").update_all("pseud_id = #{self.user.default_pseud.id}") ChallengeAssignment.where("pinch_hitter_id = #{self.id}").update_all("pinch_hitter_id = #{self.user.default_pseud.id}") return end def change_gift_recipients Gift.where("pseud_id = #{self.id}").update_all("pseud_id = #{self.user.default_pseud.id}") end def change_bookmarks_ownership Bookmark.where("pseud_id = #{self.id}").update_all("pseud_id = #{self.user.default_pseud.id}") end def change_collections_membership CollectionParticipant.where("pseud_id = #{self.id}").update_all("pseud_id = #{self.user.default_pseud.id}") end def check_default_pseud if !self.is_default? && self.user.pseuds.to_enum.find(&:is_default?) == nil default_pseud = self.user.pseuds.select{|ps| ps.name.downcase == self.user_name.downcase}.first default_pseud.update_attribute(:is_default, true) end end def expire_caches if saved_change_to_name? works.touch_all series.each(&:expire_byline_cache) chapters.each(&:expire_byline_cache) end end def touch_comments comments.touch_all end # Delete current icon (thus reverting to archive default icon) def delete_icon=(value) @delete_icon = !value.to_i.zero? end def delete_icon !!@delete_icon end alias_method :delete_icon?, :delete_icon def clear_icon return unless delete_icon? self.icon.purge self.icon_alt_text = nil self.icon_comment_text = nil end ################################# ## SEARCH ####################### ################################# def collection_ids collections.pluck(:id) end def document_json PseudIndexer.new({}).document(self) end def should_reindex_creations? pertinent_attributes = %w[id name] destroyed? || (saved_changes.keys & pertinent_attributes).present? end # If the pseud gets renamed, anything indexed with the old name needs to be reindexed: # works, series, bookmarks. def reindex_creations return unless should_reindex_creations? IndexQueue.enqueue_ids(Work, works.pluck(:id), :main) IndexQueue.enqueue_ids(Bookmark, bookmarks.pluck(:id), :main) IndexQueue.enqueue_ids(Series, series.pluck(:id), :main) end def reindex_user after_commit { user.enqueue_to_index } end end