otwarchive-symphonyarchive/app/models/collection.rb
2026-03-11 22:22:11 +00:00

464 lines
19 KiB
Ruby
Executable file

class Collection < ApplicationRecord
include Filterable
include WorksOwner
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: %r{image/\S+},
maximum_size: ArchiveConfig.ICON_SIZE_KB_MAX.kilobytes
}
belongs_to :parent, class_name: "Collection", inverse_of: :children
has_many :children, class_name: "Collection", foreign_key: "parent_id", inverse_of: :parent
has_one :collection_profile, dependent: :destroy
accepts_nested_attributes_for :collection_profile
has_one :collection_preference, dependent: :destroy
accepts_nested_attributes_for :collection_preference
before_validation :clear_icon
before_validation :cleanup_url
before_create :ensure_associated
def ensure_associated
self.collection_preference = CollectionPreference.new unless self.collection_preference
self.collection_profile = CollectionProfile.new unless self.collection_profile
end
belongs_to :challenge, dependent: :destroy, polymorphic: true
has_many :prompts, dependent: :destroy
has_many :signups, class_name: "ChallengeSignup", dependent: :destroy
has_many :potential_matches, dependent: :destroy
has_many :assignments, class_name: "ChallengeAssignment", dependent: :destroy
has_many :claims, class_name: "ChallengeClaim", dependent: :destroy
# We need to get rid of all of these if the challenge is destroyed
after_save :clean_up_challenge
def clean_up_challenge
return if self.challenge_id
assignments.each(&:destroy)
potential_matches.each(&:destroy)
signups.each(&:destroy)
prompts.each(&:destroy)
end
has_many :collection_items, dependent: :destroy
accepts_nested_attributes_for :collection_items, allow_destroy: true
has_many :approved_collection_items, -> { approved_by_both }, class_name: "CollectionItem"
has_many :works, through: :collection_items, source: :item, source_type: "Work"
has_many :approved_works, -> { posted }, through: :approved_collection_items, source: :item, source_type: "Work"
has_many :bookmarks, through: :collection_items, source: :item, source_type: "Bookmark"
has_many :approved_bookmarks, through: :approved_collection_items, source: :item, source_type: "Bookmark"
has_many :collection_participants, dependent: :destroy
accepts_nested_attributes_for :collection_participants, allow_destroy: true
has_many :participants, through: :collection_participants, source: :pseud
has_many :users, through: :participants, source: :user
has_many :invited, -> { where(collection_participants: { participant_role: CollectionParticipant::INVITED }) }, through: :collection_participants, source: :pseud
has_many :owners, -> { where(collection_participants: { participant_role: CollectionParticipant::OWNER }) }, through: :collection_participants, source: :pseud
has_many :moderators, -> { where(collection_participants: { participant_role: CollectionParticipant::MODERATOR }) }, through: :collection_participants, source: :pseud
has_many :members, -> { where(collection_participants: { participant_role: CollectionParticipant::MEMBER }) }, through: :collection_participants, source: :pseud
has_many :posting_participants, -> { where(collection_participants: { participant_role: [CollectionParticipant::MEMBER, CollectionParticipant::MODERATOR, CollectionParticipant::OWNER] }) }, through: :collection_participants, source: :pseud
CHALLENGE_TYPE_OPTIONS = [
["", ""],
[ts("Gift Exchange"), "GiftExchange"],
[ts("Prompt Meme"), "PromptMeme"]
].freeze
validate :must_have_owners
def must_have_owners
# we have to use collection participants because the association may not exist until after
# the collection is saved
errors.add(:base, ts("Collection has no valid owners.")) if (self.collection_participants + (self.parent ? self.parent.collection_participants : [])).select(&:is_owner?)
.empty?
end
validate :collection_depth
def collection_depth
errors.add(:base, ts("Sorry, but %{name} is a subcollection, so it can't also be a parent collection.", name: parent.name)) if self.parent&.parent || (self.parent && !self.children.empty?) || (!self.children.empty? && !self.children.collect(&:children).flatten.empty?)
end
validate :parent_exists
def parent_exists
errors.add(:base, ts("We couldn't find a collection with name %{name}.", name: parent_name)) unless parent_name.blank? || Collection.find_by(name: parent_name)
end
validate :parent_is_allowed
def parent_is_allowed
if parent
if parent == self
errors.add(:base, ts("You can't make a collection its own parent."))
elsif parent_id_changed? && !parent.user_is_maintainer?(User.current_user)
errors.add(:base, ts("You have to be a maintainer of %{name} to make a subcollection.", name: parent.name))
end
end
end
validates :name, presence: { message: ts("Please enter a name for your collection.") }
validates :name, uniqueness: { message: ts("Sorry, that name is already taken. Try again, please!") }
validates :name,
length: { minimum: ArchiveConfig.TITLE_MIN,
too_short: ts("must be at least %{min} characters long.", min: ArchiveConfig.TITLE_MIN) }
validates :name,
length: { maximum: ArchiveConfig.TITLE_MAX,
too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.TITLE_MAX) }
validates :name,
format: { message: ts("must begin and end with a letter or number; it may also contain underscores. It may not contain any other characters, including spaces."),
with: /\A[A-Za-z0-9]\w*[A-Za-z0-9]\Z/ }
validates :icon_alt_text, length: { allow_blank: true, maximum: ArchiveConfig.ICON_ALT_MAX,
too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.ICON_ALT_MAX) }
validates :icon_comment_text, length: { allow_blank: true, maximum: ArchiveConfig.ICON_COMMENT_MAX,
too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.ICON_COMMENT_MAX) }
validates :email, email_format: { allow_blank: true }
validates :title, presence: { message: ts("Please enter a title to be displayed for your collection.") }
validates :title,
length: { minimum: ArchiveConfig.TITLE_MIN,
too_short: ts("must be at least %{min} characters long.", min: ArchiveConfig.TITLE_MIN) }
validates :title,
length: { maximum: ArchiveConfig.TITLE_MAX,
too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.TITLE_MAX) }
validate :no_reserved_strings
def no_reserved_strings
errors.add(:title, ts("^Sorry, the ',' character cannot be in a collection Display Title.")) if
title.match(/,/)
end
# return title.html_safe to overcome escaping done by sanitiser
def title
self[:title].try(:html_safe)
end
validates :description,
length: { allow_blank: true,
maximum: ArchiveConfig.SUMMARY_MAX,
too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.SUMMARY_MAX) }
validates :header_image_url, format: { allow_blank: true, with: URI::DEFAULT_PARSER.make_regexp(%w[http https]), message: ts("is not a valid URL.") }
validates :header_image_url, format: { allow_blank: true, with: /\A\S+\.(png|gif|jpg)\z/, message: ts("can only point to a gif, jpg, or png file.") }
validates :tags_after_saving,
length: { maximum: ArchiveConfig.COLLECTION_TAGS_MAX,
message: "^Sorry, a collection can only have %{count} tags." }
scope :top_level, -> { where(parent_id: nil) }
scope :closed, -> { joins(:collection_preference).where(collection_preferences: { closed: true }) }
scope :not_closed, -> { joins(:collection_preference).where(collection_preferences: { closed: false }) }
scope :moderated, -> { joins(:collection_preference).where(collection_preferences: { moderated: true }) }
scope :unmoderated, -> { joins(:collection_preference).where(collection_preferences: { moderated: false }) }
scope :unrevealed, -> { joins(:collection_preference).where(collection_preferences: { unrevealed: true }) }
scope :anonymous, -> { joins(:collection_preference).where(collection_preferences: { anonymous: true }) }
scope :no_challenge, -> { where(challenge_type: nil) }
scope :gift_exchange, -> { where(challenge_type: "GiftExchange") }
scope :prompt_meme, -> { where(challenge_type: "PromptMeme") }
scope :name_only, -> { select("collections.name") }
scope :by_title, -> { order(:title) }
scope :for_blurb, -> { includes(:parent, :moderators, :children, :collection_preference, owners: [:user]).with_attached_icon }
def cleanup_url
self.header_image_url = Addressable::URI.heuristic_parse(self.header_image_url) if self.header_image_url
end
# Get only collections with running challenges
def self.signup_open(challenge_type)
case challenge_type
when "PromptMeme"
not_closed.where(challenge_type: challenge_type)
.joins("INNER JOIN prompt_memes on prompt_memes.id = challenge_id").where("prompt_memes.signup_open = 1")
.where("prompt_memes.signups_close_at > ?", Time.zone.now).order("prompt_memes.signups_close_at DESC")
when "GiftExchange"
not_closed.where(challenge_type: challenge_type)
.joins("INNER JOIN gift_exchanges on gift_exchanges.id = challenge_id").where("gift_exchanges.signup_open = 1")
.where("gift_exchanges.signups_close_at > ?", Time.zone.now).order("gift_exchanges.signups_close_at DESC")
end
end
scope :with_name_like, lambda { |name|
where("collections.name LIKE ?", "%#{name}%")
.limit(10)
}
scope :with_title_like, lambda { |title|
where("collections.title LIKE ?", "%#{title}%")
}
scope :with_item_count, lambda {
select("collections.*, count(distinct collection_items.id) as item_count")
.joins("left join collections child_collections on child_collections.parent_id = collections.id
left join collection_items on ( (collection_items.collection_id = child_collections.id OR collection_items.collection_id = collections.id)
AND collection_items.user_approval_status = 1
AND collection_items.collection_approval_status = 1)")
.group("collections.id")
}
def to_param
name_was
end
# Change membership of collection(s) from a particular pseud to the orphan account
def self.orphan(pseuds, collections, default: true)
pseuds.each do |pseud|
collections.each do |collection|
if pseud && collection && collection.owners.include?(pseud)
orphan_pseud = default ? User.orphan_account.default_pseud : User.orphan_account.pseuds.find_or_create_by(name: pseud.name)
pseud.change_membership(collection, orphan_pseud)
end
end
end
end
## AUTOCOMPLETE
# set up autocomplete and override some methods
include AutocompleteSource
def autocomplete_search_string
"#{name} #{title}"
end
def autocomplete_search_string_before_last_save
"#{name_before_last_save} #{title_before_last_save}"
end
def autocomplete_prefixes
["autocomplete_collection_all",
"autocomplete_collection_#{closed? ? 'closed' : 'open'}"]
end
def autocomplete_score
all_items.approved_by_collection.approved_by_user.count
end
## END AUTOCOMPLETE
def parent_name=(name)
@parent_name = name
self.parent = Collection.find_by(name: name)
end
def parent_name
@parent_name || (self.parent ? self.parent.name : "")
end
def all_owners
(self.owners + (self.parent ? self.parent.owners : [])).uniq
end
def all_moderators
(self.moderators + (self.parent ? self.parent.moderators : [])).uniq
end
def all_members
(self.members + (self.parent ? self.parent.members : [])).uniq
end
def all_posting_participants
(self.posting_participants + (self.parent ? self.parent.posting_participants : [])).uniq
end
def all_participants
(self.participants + (self.parent ? self.parent.participants : [])).uniq
end
def all_items
CollectionItem.where(collection_id: ([self.id] + self.children.pluck(:id)))
end
def maintainers
self.all_owners + self.all_moderators
end
def user_is_owner?(user)
user && user != false && !(user.pseuds & self.all_owners).empty?
end
def user_is_moderator?(user)
user && user != false && !(user.pseuds & self.all_moderators).empty?
end
def user_is_maintainer?(user)
user && user != false && !(user.pseuds & (self.all_moderators + self.all_owners)).empty?
end
def user_is_participant?(user)
user && user != false && !get_participating_pseuds_for_user(user).empty?
end
def user_is_posting_participant?(user)
user && user != false && !(user.pseuds & self.all_posting_participants).empty?
end
def get_participating_pseuds_for_user(user)
(user && user != false) ? user.pseuds & self.all_participants : []
end
def get_participants_for_user(user)
return [] unless user
CollectionParticipant.in_collection(self).for_user(user)
end
def assignment_notification
self.collection_profile.assignment_notification || (parent ? parent.collection_profile.assignment_notification : "")
end
def gift_notification
self.collection_profile.gift_notification || (parent ? parent.collection_profile.gift_notification : "")
end
def moderated?() = self.collection_preference.moderated
def closed?() = self.collection_preference.closed
def unrevealed?() = self.collection_preference.unrevealed
def anonymous?() = self.collection_preference.anonymous
def challenge?() = !self.challenge.nil?
def gift_exchange?
self.challenge_type == "GiftExchange"
end
def prompt_meme?
self.challenge_type == "PromptMeme"
end
def maintainers_list
self.maintainers.collect(&:user).flatten.uniq
end
def collection_email
return self.email if self.email.present?
return parent.email if parent && parent.email.present?
end
def notify_maintainers_assignments_sent
subject = I18n.t("user_mailer.collection_notification.assignments_sent.subject")
message = I18n.t("user_mailer.collection_notification.assignments_sent.complete")
if self.collection_email.present?
UserMailer.collection_notification(self.id, subject, message, self.collection_email).deliver_later
else
# if collection email is not set and collection parent email is not set, loop through maintainers and send each a notice via email
self.maintainers_list.each do |user|
I18n.with_locale(user.preference.locale_for_mails) do
translated_subject = I18n.t("user_mailer.collection_notification.assignments_sent.subject")
translated_message = I18n.t("user_mailer.collection_notification.assignments_sent.complete")
UserMailer.collection_notification(self.id, translated_subject, translated_message, user.email).deliver_later
end
end
end
end
def notify_maintainers_challenge_default(challenge_assignment, assignments_page_url)
if self.collection_email.present?
subject = I18n.t("user_mailer.collection_notification.challenge_default.subject", offer_byline: challenge_assignment.offer_byline)
message = I18n.t("user_mailer.collection_notification.challenge_default.complete", offer_byline: challenge_assignment.offer_byline, request_byline: challenge_assignment.request_byline, assignments_page_url: assignments_page_url)
UserMailer.collection_notification(self.id, subject, message, self.collection_email).deliver_later
else
# if collection email is not set and collection parent email is not set, loop through maintainers and send each a notice via email
self.maintainers_list.each do |user|
I18n.with_locale(user.preference.locale_for_mails) do
translated_subject = I18n.t("user_mailer.collection_notification.challenge_default.subject", offer_byline: challenge_assignment.offer_byline)
translated_message = I18n.t("user_mailer.collection_notification.challenge_default.complete", offer_byline: challenge_assignment.offer_byline, request_byline: challenge_assignment.request_byline, assignments_page_url: assignments_page_url)
UserMailer.collection_notification(self.id, translated_subject, translated_message, user.email).deliver_later
end
end
end
end
include AsyncWithResque
@queue = :collection
def reveal!
async(:reveal_collection_items)
end
def reveal_authors!
async(:reveal_collection_item_authors)
end
def reveal_collection_items
approved_collection_items.each { |collection_item| collection_item.update_attribute(:unrevealed, false) }
send_reveal_notifications
end
def reveal_collection_item_authors
approved_collection_items.each { |collection_item| collection_item.update_attribute(:anonymous, false) }
end
def send_reveal_notifications
approved_collection_items.each(&:notify_of_reveal)
end
def self.sorted_and_filtered(sort, filters, page)
pagination_args = { page: page }
# build up the query with scopes based on the options the user specifies
query = Collection.top_level
if filters[:title].present?
# we get the matching collections out of autocomplete and use their ids
ids = Collection.autocomplete_lookup(search_param: filters[:title],
autocomplete_prefix: (if filters[:closed].blank?
"autocomplete_collection_all"
else
(filters[:closed] ? "autocomplete_collection_closed" : "autocomplete_collection_open")
end)).map { |result| Collection.id_from_autocomplete(result) }
query = query.where(collections: { id: ids })
elsif filters[:closed].present?
query = (filters[:closed] == "true" ? query.closed : query.not_closed)
end
query = (filters[:moderated] == "true" ? query.moderated : query.unmoderated) if filters[:moderated].present?
if filters[:challenge_type].present?
case filters[:challenge_type]
when "gift_exchange"
query = query.gift_exchange
when "prompt_meme"
query = query.prompt_meme
when "no_challenge"
query = query.no_challenge
end
end
query = query.order(sort).for_blurb
if filters[:fandom].blank?
query.paginate(pagination_args)
else
fandom = Fandom.find_by_name(filters[:fandom])
if fandom
(fandom.approved_collections & query).paginate(pagination_args)
else
[]
end
end
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 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
end