otwarchive-symphonyarchive/app/models/pseud.rb

456 lines
16 KiB
Ruby
Raw Normal View History

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