diff --git a/app/controllers/bookmarks_controller.rb b/app/controllers/bookmarks_controller.rb index 0f25334160c..93bf81f2651 100644 --- a/app/controllers/bookmarks_controller.rb +++ b/app/controllers/bookmarks_controller.rb @@ -208,56 +208,9 @@ def create # PUT /bookmarks/1 # PUT /bookmarks/1.xml def update - new_collections = [] - unapproved_collections = [] - errors = [] - bookmark_params[:collection_names]&.split(",")&.map(&:strip)&.uniq&.each do |collection_name| - collection = Collection.find_by(name: collection_name) - if collection.nil? - errors << ts("%{name} does not exist.", name: collection_name) - else - if @bookmark.collections.include?(collection) - next - elsif collection.closed? && !collection.user_is_maintainer?(User.current_user) - errors << ts("%{title} is closed to new submissions.", title: collection.title) - elsif @bookmark.add_to_collection(collection) && @bookmark.save - if @bookmark.approved_collections.include?(collection) - new_collections << collection - else - unapproved_collections << collection - end - else - errors << ts("Something went wrong trying to add collection %{title}, sorry!", title: collection.title) - end - end - end - - # messages to the user - unless errors.empty? - flash[:error] = ts("We couldn't add your submission to the following collections: ") + errors.join("
") - end - - unless new_collections.empty? - flash[:notice] = ts("Added to collection(s): %{collections}.", - collections: new_collections.collect(&:title).join(", ")) - end - unless unapproved_collections.empty? - flash[:notice] = flash[:notice] ? flash[:notice] + " " : "" - flash[:notice] += if unapproved_collections.size > 1 - ts("You have submitted your bookmark to moderated collections (%{all_collections}). It will not become a part of those collections until it has been approved by a moderator.", all_collections: unapproved_collections.map(&:title).join(", ")) - else - ts("You have submitted your bookmark to the moderated collection '%{collection}'. It will not become a part of the collection until it has been approved by a moderator.", collection: unapproved_collections.first.title) - end - end - - flash[:notice] = (flash[:notice]).html_safe unless flash[:notice].blank? - flash[:error] = (flash[:error]).html_safe unless flash[:error].blank? - - if @bookmark.update(bookmark_params) && errors.empty? - flash[:notice] = flash[:notice] ? " " + flash[:notice] : "" - flash[:notice] = ts("Bookmark was successfully updated.").html_safe + flash[:notice] - flash[:notice] += t("bookmarks.create.warnings.private_bookmark_added_to_collection") if new_collections.any? || unapproved_collections.any? - flash[:notice] = flash[:notice].html_safe + if @bookmark.update(bookmark_params) + flash[:notice] = t("bookmarks.update.bookmark_updated") + new_moderated_collections_message redirect_to(@bookmark) else @bookmarkable = @bookmark.bookmarkable @@ -351,6 +304,27 @@ def set_own_bookmarks end end + # Flash a message about the new moderated collections that the user has + # submitted their bookmark to. + def new_moderated_collections_message + new_moderated_collections = @bookmark.collection_items + .select(&:previously_new_record?) + .select(&:unreviewed_by_collection?) + .map(&:collection) + + return if new_moderated_collections.blank? + + links = new_moderated_collections.map do |collection| + view_context.link_to(collection.title, collection_path(collection)) + end + + message = t("bookmarks.new_moderated_collections_message_html", + count: new_moderated_collections.length, + collections: view_context.safe_join(links, ", ")) + + flash[:notice] = view_context.safe_join([flash[:notice], message].compact, " ") + end + private def bookmark_params diff --git a/app/controllers/collection_items_controller.rb b/app/controllers/collection_items_controller.rb index 86a70880761..a58b24ade02 100644 --- a/app/controllers/collection_items_controller.rb +++ b/app/controllers/collection_items_controller.rb @@ -107,8 +107,8 @@ def create errors << ts("%{collection_title}, either you don't own this item or are not a moderator of the collection.", collection_title: collection.title) elsif @item.is_a?(Work) && @item.anonymous? && !current_user.is_author_of?(@item) errors << ts("%{collection_title}, because you don't own this item and the item is anonymous.", collection_title: collection.title) - # add the work to a collection, and try to save it - elsif @item.add_to_collection(collection) && @item.save(validate: false) + # add the work to a collection + elsif @item.add_to_collection(collection) # approved_by_user? and approved_by_collection? are both true. # This is will be true for archivists adding works to collections they maintain # or creators adding their works to a collection with auto-approval. diff --git a/app/controllers/works_controller.rb b/app/controllers/works_controller.rb index 61e6ad43a02..5ba6db4830a 100755 --- a/app/controllers/works_controller.rb +++ b/app/controllers/works_controller.rb @@ -362,7 +362,7 @@ def update elsif params[:preview_button] flash[:notice] = t(".unposted_notice") unless @work.posted? - in_moderated_collection + in_moderated_collection(flash.now) @preview_mode = true render :preview else @@ -566,22 +566,23 @@ def send_external_invites(works) end # check to see if the work is being added / has been added to a moderated collection, then let user know that - def in_moderated_collection - moderated_collections = [] - @work.collections.each do |collection| - next unless !collection.nil? && collection.moderated? && !collection.user_is_posting_participant?(current_user) - next unless @work.collection_items.present? - @work.collection_items.each do |collection_item| - next unless collection_item.collection == collection - if collection_item.approved_by_user? && collection_item.unreviewed_by_collection? - moderated_collections << collection - end - end - end - if moderated_collections.present? - flash[:notice] ||= '' - flash[:notice] += ts(" You have submitted your work to #{moderated_collections.size > 1 ? 'moderated collections (%{all_collections}). It will not become a part of those collections' : "the moderated collection '%{all_collections}'. It will not become a part of the collection"} until it has been approved by a moderator.", all_collections: moderated_collections.map(&:title).join(', ')) + def in_moderated_collection(flash = self.flash) + moderated_collections = @work.collection_items_after_saving + .select(&:approved_by_user?) + .select(&:unreviewed_by_collection?) + .map(&:collection) + + return if moderated_collections.blank? + + links = moderated_collections.map do |collection| + view_context.link_to(collection.title, collection_path(collection)) end + + message = t("works.moderated_collections_message_html", + count: moderated_collections.length, + collections: view_context.safe_join(links, ", ")) + + flash[:notice] = view_context.safe_join([flash[:notice], message].compact, " ") end public @@ -786,7 +787,7 @@ def set_work_form_fields @serial_works = @work.serial_works if @collection.nil? - @collection = @work.approved_collections.first + @collection = @work.approved_collections_after_saving.first end if params[:claim_id] diff --git a/app/helpers/works_helper.rb b/app/helpers/works_helper.rb index da6bd272e84..a684fc04133 100644 --- a/app/helpers/works_helper.rb +++ b/app/helpers/works_helper.rb @@ -30,6 +30,15 @@ def work_meta_list(work, chapter = nil) content_tag(:dl, list.to_s, class: 'stats').html_safe end + def collections_meta_tag(work) + collections_data = show_collections_data(work.approved_collections_after_saving) + return if collections_data.blank? + + content_tag(:dt, t("works_helper.collections_meta_tag.collections"), class: "collections") + + "\n".html_safe + + content_tag(:dd, collections_data, class: "collections") + end + def work_page_title(work, title, options = {}) fandoms = work.fandoms title_fandom = if fandoms.empty? diff --git a/app/models/application_record.rb b/app/models/application_record.rb index b05045407ac..4d7700005c8 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -15,4 +15,8 @@ def update_sanitizer_version def self.random_order order(Arel.sql("RAND()")) end + + def unmark_for_destruction + @marked_for_destruction = false + end end diff --git a/app/models/collection.rb b/app/models/collection.rb index b51005911f7..c4b5b4f154e 100755 --- a/app/models/collection.rb +++ b/app/models/collection.rb @@ -18,10 +18,10 @@ class Collection < ApplicationRecord 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 + accepts_nested_attributes_for :collection_profile, update_only: true has_one :collection_preference, dependent: :destroy - accepts_nested_attributes_for :collection_preference + accepts_nested_attributes_for :collection_preference, update_only: true before_validation :clear_icon before_validation :cleanup_url @@ -297,13 +297,11 @@ 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 + delegate :moderated?, + :closed?, + :unrevealed?, + :anonymous?, + to: :collection_preference, allow_nil: true def challenge?() = !self.challenge.nil? diff --git a/app/models/collection_item.rb b/app/models/collection_item.rb index 1c2dd9ddb57..f2489b85d0b 100644 --- a/app/models/collection_item.rb +++ b/app/models/collection_item.rb @@ -5,13 +5,10 @@ class CollectionItem < ApplicationRecord [ts("Rejected"), :rejected] ] - belongs_to :collection, inverse_of: :collection_items + belongs_to :collection, inverse_of: :collection_items, autosave: false belongs_to :item, polymorphic: :true, inverse_of: :collection_items, touch: true - belongs_to :work, class_name: "Work", foreign_key: "item_id", inverse_of: :collection_items - belongs_to :bookmark, class_name: "Bookmark", foreign_key: "item_id" - validates_uniqueness_of :collection_id, scope: [:item_id, :item_type], - message: ts("already contains this item.") + validates :collection_id, uniqueness: { scope: [:item_id, :item_type] } enum :user_approval_status, { rejected: -1, @@ -25,11 +22,21 @@ class CollectionItem < ApplicationRecord approved: 1 }, suffix: :by_collection + validate :collection_must_exist, on: :create + def collection_must_exist + if collection.nil? + errors.add(:collection, :blank) + elsif collection.new_record? + errors.add(:collection, :not_found, name: collection.name) + end + end + validate :collection_is_open, on: :create def collection_is_open - if self.new_record? && self.collection && self.collection.closed? && !self.collection.user_is_maintainer?(User.current_user) - errors.add(:base, ts("The collection %{title} is not currently open.", title: self.collection.title)) - end + return unless collection.present? && collection.closed? && + !collection.user_is_maintainer?(User.current_user) + + errors.add(:collection, :closed, title: collection.title) end scope :include_for_works, -> { includes(item: :pseuds) } @@ -47,46 +54,110 @@ def self.for_user(user=User.current_user) scope :invited_by_collection, -> { approved_by_collection.unreviewed_by_user } scope :approved_by_both, -> { approved_by_collection.approved_by_user } - before_save :set_anonymous_and_unrevealed + before_validation :set_anonymous_and_unrevealed, on: :create def set_anonymous_and_unrevealed - if self.new_record? && collection - self.unrevealed = true if collection.reload.unrevealed? - self.anonymous = true if collection.reload.anonymous? + return unless collection + + self.unrevealed = collection.unrevealed? + self.anonymous = collection.anonymous? + end + + before_validation :approve_automatically, on: :create + def approve_automatically + return unless item && collection + + # approve with the current user, who is the person who has just + # added this item -- might be either moderator or owner + approve(User.current_user) + + # if the collection is open or the user who owns this work is a member, go ahead and approve + # for the collection + return if approved_by_collection? + + approve_by_collection if !collection.moderated? || + collection.user_is_posting_participant?(User.current_user) + end + + before_save :send_work_invitation + def send_work_invitation + return if approved_by_user? || !approved_by_collection? || !self.new_record? || User.current_user.is_author_of?(item) + + # a maintainer is attempting to add this work to their collection + # so we send an email to all the works owners + item.users.each do |email_author| + next if email_author.preference.collection_emails_off + + I18n.with_locale(email_author.preference.locale_for_mails) do + UserMailer.invited_to_collection_notification(email_author.id, item.id, collection.id).deliver_now + end end end + def destroyed_by_item? + item && destroyed_by_association && + item.association(:collection_items).reflection == destroyed_by_association + end + + after_update :notify_of_unrevealed_or_anonymous + def notify_of_unrevealed_or_anonymous + # This CollectionItem's anonymous/unrevealed status can only affect the + # item's status if (a) the CollectionItem is approved by the user and (b) + # the item is a work. (Bookmarks can't be anonymous/unrevealed at the + # moment.) + return unless approved_by_user? && item.is_a?(Work) + + # Check whether anonymous/unrevealed is becoming true, when the work + # currently has it set to false: + newly_anonymous = (saved_change_to_anonymous?(to: true) && !item.anonymous?) + newly_unrevealed = (saved_change_to_unrevealed?(to: true) && !item.unrevealed?) + + return unless newly_unrevealed || newly_anonymous + + # Don't notify if it's one of the work creators who is changing the work's + # status. + return if item.users.include?(User.current_user) + + item.users.each do |user| + I18n.with_locale(user.preference.locale_for_mails) do + UserMailer.anonymous_or_unrevealed_notification( + user.id, item.id, collection.id, + anonymous: newly_anonymous, unrevealed: newly_unrevealed + ).deliver_after_commit + end + end + end + + after_destroy :update_work, unless: :destroyed_by_item? + after_destroy :reindex_item, unless: :destroyed_by_item? + after_destroy :expire_caches + def expire_caches + return unless self.item.respond_to?(:expire_caches) + + self.item.expire_caches + CacheMaster.record(item_id, "collection", collection_id) + end + after_save :update_work - after_destroy :update_work # Set associated works to anonymous or unrevealed as appropriate. - # - # Inverses are set up properly on self.item, so we use that field to check - # whether we're currently in the process of saving a brand new work, or - # whether the work is in the process of being destroyed. (In which case we - # rely on the Work's callbacks to set anon/unrevealed status properly.) But - # because we want to discard changes made in preview mode, we perform the - # actual anon/unrevealed updates on self.work, which doesn't have proper - # inverses and therefore is freshly loaded from the database. def update_work - return unless item.is_a?(Work) && item.persisted? && !item.saved_change_to_id? + return unless item.is_a?(Work) && item.persisted? - if work.present? - work.update_anon_unrevealed + item.set_anon_unrevealed - # For a more helpful error message, raise an error saying that the work - # is invalid if we fail to save it. - raise ActiveRecord::RecordInvalid, work unless work.save(validate: false) - end + item.save!(validate: false) if item.will_save_change_to_anonymous? || + item.will_save_change_to_unrevealed? end - # Poke the item if it's just been approved or unapproved so it gets picked up by the search index - after_update :update_item_for_status_change - def update_item_for_status_change - if saved_change_to_user_approval_status? || saved_change_to_collection_approval_status? - item.save!(validate: false) - end + after_save :reindex_item + def reindex_item + item&.enqueue_to_index end + # reindex collection after creation, deletion, and approval_status update + # (we only index approved items, which is why changes there trigger the reindex-index) + after_commit :update_collection_index, if: :should_update_collection_index? + after_create_commit :notify_of_association def notify_of_association email_notify = self.collection.collection_preference && @@ -116,44 +187,18 @@ def notify_archivist_added end end - before_save :approve_automatically - def approve_automatically - return unless self.new_record? - - # approve with the current user, who is the person who has just - # added this item -- might be either moderator or owner - # rubocop:disable Lint/BooleanSymbol - approve(User.current_user == :false ? nil : User.current_user) - # rubocop:enable Lint/BooleanSymbol - - # if the collection is open or the user who owns this work is a member, go ahead and approve - # for the collection - return unless !approved_by_collection? && collection - - approve_by_collection if !collection.moderated? || collection.user_is_maintainer?(User.current_user) || collection.user_is_posting_participant?(User.current_user) + def update_collection_index + ids = [collection_id] + ids.push(collection.parent_id) if collection.parent.present? + IndexQueue.enqueue_ids(Collection, ids, :background) end - before_save :send_work_invitation - def send_work_invitation - return if approved_by_user? || !approved_by_collection? || !self.new_record? || User.current_user.is_author_of?(item) - - # a maintainer is attempting to add this work to their collection - # so we send an email to all the works owners - item.users.each do |email_author| - next if email_author.preference.collection_emails_off - - I18n.with_locale(email_author.preference.locale_for_mails) do - UserMailer.invited_to_collection_notification(email_author.id, item.id, collection.id).deliver_now - end - end - end + # reindex collection after creation, deletion, and certain attribute updates + def should_update_collection_index? + return true if destroyed? - after_destroy :expire_caches - def expire_caches - if self.item.respond_to?(:expire_caches) - self.item.expire_caches - CacheMaster.record(item_id, 'collection', collection_id) - end + pertinent_attributes = %w[collection_approval_status user_approval_status] + (self.saved_changes.keys & pertinent_attributes).present? end attr_writer :remove @@ -206,8 +251,7 @@ def approve(user) approve_by_user approve_by_collection else - author_of_item = user.is_author_of?(item) || - (user == User.current_user && item.respond_to?(:pseuds) ? item.pseuds.empty? : item.pseud.nil?) + author_of_item = user.is_author_of?(item) || (user == User.current_user && item.new_record?) archivist_maintainer = user.archivist && self.collection.user_is_maintainer?(user) approve_by_user if author_of_item || archivist_maintainer approve_by_collection if self.collection.user_is_maintainer?(user) @@ -249,51 +293,4 @@ def notify_of_reveal end end end - - after_update :notify_of_unrevealed_or_anonymous - def notify_of_unrevealed_or_anonymous - # This CollectionItem's anonymous/unrevealed status can only affect the - # item's status if (a) the CollectionItem is approved by the user and (b) - # the item is a work. (Bookmarks can't be anonymous/unrevealed at the - # moment.) - return unless approved_by_user? && item.is_a?(Work) - - # Check whether anonymous/unrevealed is becoming true, when the work - # currently has it set to false: - newly_anonymous = (saved_change_to_anonymous?(to: true) && !item.anonymous?) - newly_unrevealed = (saved_change_to_unrevealed?(to: true) && !item.unrevealed?) - - return unless newly_unrevealed || newly_anonymous - - # Don't notify if it's one of the work creators who is changing the work's - # status. - return if item.users.include?(User.current_user) - - item.users.each do |user| - I18n.with_locale(user.preference.locale_for_mails) do - UserMailer.anonymous_or_unrevealed_notification( - user.id, item.id, collection.id, - anonymous: newly_anonymous, unrevealed: newly_unrevealed - ).deliver_after_commit - end - end - end - - # reindex collection after creation, deletion, and approval_status update - # (we only index approved items, which is why changes there trigger the reindex-index) - after_commit :update_collection_index, if: :should_update_collection_index? - - def update_collection_index - ids = [collection_id] - ids.push(collection.parent_id) if collection.parent.present? - IndexQueue.enqueue_ids(Collection, ids, :background) - end - - # reindex collection after creation, deletion, and certain attribute updates - def should_update_collection_index? - return true if destroyed? - - pertinent_attributes = %w[collection_approval_status user_approval_status] - (self.saved_changes.keys & pertinent_attributes).present? - end end diff --git a/app/models/concerns/association_assignment.rb b/app/models/concerns/association_assignment.rb new file mode 100644 index 00000000000..077c0a1325b --- /dev/null +++ b/app/models/concerns/association_assignment.rb @@ -0,0 +1,30 @@ +module AssociationAssignment + extend ActiveSupport::Concern + + # Given an association of through records, the name of the "source" field, + # and a list of values, marks the through records as destroyed or not based + # on whether their "source" value is included in the list of desired values, + # and builds new through records when necessary. + # + # The optional parameter klass is used to filter the through records -- when + # it's set, this function won't mark or unmark any through records whose + # source doesn't have that klass. + def assign_through_association(through_association, source, values, klass: nil) + missing = Set.new(values) + + through_association.each do |through_record| + value = through_record.send(source) + next if klass && !value.is_a?(klass) + + if missing.delete?(value) + through_record.unmark_for_destruction + else + through_record.mark_for_destruction + end + end + + missing.each do |value| + through_association.build(source => value) + end + end +end diff --git a/app/models/concerns/collectible.rb b/app/models/concerns/collectible.rb new file mode 100644 index 00000000000..67305cae27d --- /dev/null +++ b/app/models/concerns/collectible.rb @@ -0,0 +1,162 @@ +module Collectible + extend ActiveSupport::Concern + include AssociationAssignment + + included do + has_many :collection_items, as: :item, inverse_of: :item, autosave: true, dependent: :destroy + + has_many :approved_collection_items, -> { approved_by_both }, + class_name: "CollectionItem", as: :item, inverse_of: :item, dependent: :destroy + has_many :user_approved_collection_items, -> { approved_by_user }, + class_name: "CollectionItem", as: :item, inverse_of: :item, dependent: :destroy + + has_many :collections, + through: :collection_items, + after_add: :recalculate_anon_unrevealed, + after_remove: :recalculate_anon_unrevealed, + dependent: :destroy + has_many :approved_collections, + through: :approved_collection_items, + source: :collection, + dependent: :destroy + has_many :user_approved_collections, + through: :user_approved_collection_items, + source: :collection, + dependent: :destroy + has_many :rejected_collections, + -> { CollectionItem.rejected_by_user }, + through: :collection_items, + source: :collection, + dependent: :destroy + + # NOTE: this scope includes the items in the children of the specified collection + scope :in_collection, lambda { |collection| + distinct.joins(:approved_collection_items).merge(collection.all_items) + } + + after_validation :set_anon_unrevealed, if: -> { collection_items.loaded? } + end + + # The collection items that will remain after this item has been saved. + def collection_items_after_saving + collection_items + .reject(&:marked_for_destruction?) + .reject(&:destroyed?) + end + + # The collections that this item will be approved in after the item has been + # saved: + def approved_collections_after_saving + if collection_items.target.empty? + approved_collections.to_a + else + collection_items_after_saving.select(&:approved?).map(&:collection) + end + end + + # All collections that this item will be included in (including rejected and + # unreviewed collections) after the item has been saved: + def collections_after_saving + if collection_items.target.empty? + collections.to_a + else + collection_items_after_saving.map(&:collection) + end + end + + # Set which collections this item should be in after saving: + def collections_after_saving=(collections) + assign_through_association(collection_items, :collection, collections) + end + + # Add collections with a comma-separated list of names: + def collections_to_add=(names) + self.collections_after_saving += parse_collection_names(names) + end + + # The collection names that are about to be added: + def collections_to_add + collection_items_after_saving + .select(&:new_record?) + .map(&:collection) + .map(&:name) + .join(",") + end + + # Remove collections by ID: + def collections_to_remove=(ids) + collections = Collection.find(ids.reject(&:blank?).map(&:to_i)) + self.collections_after_saving -= collections + end + + # The collection IDs to be removed: + def collections_to_remove + collection_items.select(&:marked_for_destruction?).map(&:collection_id) + end + + # Assign this item's collections by a comma-separated list of names: + def collection_names=(names) + self.collections_after_saving = parse_collection_names(names) + end + + # Returns the collections that this item will have after saving as a + # comma-separated list of names: + def collection_names + collections_after_saving.map(&:name).join(",") + end + + # Add the given collection and immediately save the resulting collection item: + def add_to_collection(collection) + collection_item = collection_items.find { |ci| ci.collection == collection } + collection_item ||= collection_items.build(collection: collection) + collection_item.unmark_for_destruction + + # If we're a new record, we don't need to save the collection item + # immediately, since it'll get saved when we're saved. And we don't need to + # save the collection item if it's already persisted. But otherwise, we do + # want to save it: + new_record? || collection_item.persisted? || collection_item.save + end + + #### UNREVEALED/ANONYMOUS + + def recalculate_anon_unrevealed(_collection) + set_anon_unrevealed + end + + # Set the anonymous/unrevealed status of the collectible based on its + # collection items. + def set_anon_unrevealed + return unless has_attribute?(:anonymous) && has_attribute?(:unrevealed) + + if collection_items.target.empty? + if new_record? + self.anonymous = collections.target.any?(&:anonymous?) + self.unrevealed = collections.target.any?(&:unrevealed?) + else + relevant = user_approved_collection_items + + self.anonymous = relevant.anonymous.exists? + self.unrevealed = relevant.unrevealed.exists? + end + else + relevant = collection_items_after_saving + .select(&:approved_by_user?) + + self.anonymous = relevant.any? { |ci| ci.anonymous? || (ci.new_record? && ci.collection&.anonymous?) } + self.unrevealed = relevant.any? { |ci| ci.unrevealed? || (ci.new_record? && ci.collection&.unrevealed?) } + end + end + + private + + # Given a comma-separated list of names, return a list of collections. + # + # For any names that can't be found, returns an unsaved collection with the + # desired name, so that we can include that name in error messages. + def parse_collection_names(names) + names.split(",").map(&:strip).reject(&:blank?).map do |name| + Collection.find_or_initialize_by(name: name) + end + end +end diff --git a/app/models/concerns/taggable.rb b/app/models/concerns/taggable.rb index 15386467cdb..5f0f6e05715 100644 --- a/app/models/concerns/taggable.rb +++ b/app/models/concerns/taggable.rb @@ -1,5 +1,6 @@ module Taggable extend ActiveSupport::Concern + include AssociationAssignment included do has_many :taggings, as: :taggable, inverse_of: :taggable, dependent: :destroy, autosave: true @@ -30,7 +31,7 @@ module Taggable define_method("#{underscore}_string=") do |tag_string| tags = parse_tags_of_type(tag_string, klass) - assign_tags_of_type(tags, klass) + assign_through_association(taggings, :tagger, tags, klass: klass) end alias_method "#{underscore}_strings=", "#{underscore}_string=" @@ -47,7 +48,7 @@ def tag_string def tag_string=(tag_string) tags = parse_tags(tag_string) - assign_tags_of_type(tags, Tag) + assign_through_association(taggings, :tagger, tags, klass: Tag) end alias tag_strings= tag_string= @@ -132,31 +133,6 @@ def parse_tags(tag_string) end.uniq end - # Mark taggings for destruction, and create new taggings, so that we will end - # up with the specified set of tags after saving. - # - # Only deletes/checks tags of the given class. - def assign_tags_of_type(tags, klass) - missing = Set.new(tags) - - taggings.each do |tagging| - tag = tagging.tagger - - next unless tag.is_a?(klass) - - if missing.include?(tag) - missing.delete(tag) - tagging.reload if tagging.marked_for_destruction? - else - tagging.mark_for_destruction - end - end - - missing.each do |tag| - taggings.build(tagger: tag) - end - end - def destroy_tagging(tag) taggings.find_by(tagger: tag)&.destroy end diff --git a/app/models/work.rb b/app/models/work.rb index 38549242d58..89e98194426 100755 --- a/app/models/work.rb +++ b/app/models/work.rb @@ -238,7 +238,6 @@ def new_recipients_have_not_blocked_gift_giver after_save :save_chapters, :save_new_gifts - before_create :set_anon_unrevealed after_create :notify_after_creation after_update :adjust_series_restriction, :notify_after_update @@ -579,17 +578,8 @@ def visible?(user = User.current_user) end end - def unrevealed?(user=User.current_user) - # eventually here is where we check if it's in a challenge that hasn't been made public yet - #!self.collection_items.unrevealed.empty? - in_unrevealed_collection? - end - - def anonymous?(user = User.current_user) - # here we check if the story is in a currently-anonymous challenge - #!self.collection_items.anonymous.empty? - in_anon_collection? - end + alias_attribute :unrevealed, :in_unrevealed_collection + alias_attribute :anonymous, :in_anon_collection before_update :bust_anon_caching def bust_anon_caching diff --git a/app/views/works/_meta.html.erb b/app/views/works/_meta.html.erb index 778d343ab73..c77b9d03233 100755 --- a/app/views/works/_meta.html.erb +++ b/app/views/works/_meta.html.erb @@ -49,14 +49,7 @@ <% end %> - <% unless @work.approved_collections.empty? %> -
- <%= ts('Collections:') %> -
-
- <%= show_collections_data(@work.approved_collections) %> -
- <% end %> + <%= collections_meta_tag(@work) %>
<%= ts("Stats:") %>
diff --git a/config/locales/controllers/en.yml b/config/locales/controllers/en.yml index e1198536650..1fc93312216 100644 --- a/config/locales/controllers/en.yml +++ b/config/locales/controllers/en.yml @@ -103,6 +103,11 @@ en: success: Bookmark was successfully created. It should appear in bookmark listings within the next few minutes. warnings: private_bookmark_added_to_collection: " Please note: private bookmarks are not listed in collections." + new_moderated_collections_message_html: + one: You have submitted your bookmark to the moderated collection '%{collections}'. It will not become a part of the collection until it has been approved by a moderator. + other: You have submitted your bookmark to moderated collections (%{collections}). It will not become a part of those collections until it has been approved by a moderator. + update: + bookmark_updated: Bookmark was successfully updated. challenge_assignments: index: access_denied_user: You aren't allowed to see that user's assignments. @@ -390,6 +395,9 @@ en: page_title: "%{username} - Drafts" edit_tags: page_title: Edit Work Tags + moderated_collections_message_html: + one: You have submitted your work to the moderated collection '%{collections}'. It will not become a part of the collection until it has been approved by a moderator. + other: You have submitted your work to moderated collections (%{collections}). It will not become a part of those collections until it has been approved by a moderator. new: closed_collection: Sorry, the collection %{collection_title} is closed. New works cannot be added to it. show: diff --git a/config/locales/helpers/en.yml b/config/locales/helpers/en.yml index de486305021..49538a002b6 100644 --- a/config/locales/helpers/en.yml +++ b/config/locales/helpers/en.yml @@ -77,6 +77,8 @@ en: validated: Account Validated warn: Warned works_helper: + collections_meta_tag: + collections: 'Collections:' mark_as_read_button: Mark as Read mark_for_later_button: Mark for Later work_page_title: diff --git a/config/locales/models/en.yml b/config/locales/models/en.yml index 9ad0482194c..586eb58024c 100644 --- a/config/locales/models/en.yml +++ b/config/locales/models/en.yml @@ -112,10 +112,21 @@ en: attributes: pseud: required: can't be blank + bookmark/collection_items: + format: "%{message}" collection: attributes: header_image_url: file_format: can only point to a gif, jpg, jpeg, or png file. + collection_item: + attributes: + collection: + blank: The collection can't be blank. + closed: The collection %{title} is not currently open. + not_found: We couldn't find the collection %{name}. + collection_id: + taken: The collection already contains this item. + format: "%{message}" comment: attributes: comment_content: @@ -247,6 +258,8 @@ en: at_most: must not add up to more than %{count}. Your work has %{value} of these tags, so you must remove %{diff} of them. blocked_gifts: "%{byline} does not accept gifts." blocked_your_gifts: "%{byline} does not accept gifts from you." + work/collection_items: + format: "%{message}" work/parent_work_relationships: format: "%{message}" models: diff --git a/factories/collections.rb b/factories/collections.rb index 12e3ed5aae3..6ee9a727db3 100644 --- a/factories/collections.rb +++ b/factories/collections.rb @@ -54,7 +54,7 @@ end factory :collection_item do - item_type { "Work" } + item { create(:work) } collection end end diff --git a/features/bookmarks/bookmark_create.feature b/features/bookmarks/bookmark_create.feature index a5c3e97fcac..dbb5b88cfe3 100644 --- a/features/bookmarks/bookmark_create.feature +++ b/features/bookmarks/bookmark_create.feature @@ -249,7 +249,7 @@ Scenario: Adding bookmark to non-existent collection (AO3-4338) Then I follow "Edit" And I fill in "bookmark_collection_names" with "some_nonsense_collection" And I press "Update" - And I should see "does not exist." + And I should see "We couldn't find the collection some_nonsense_collection." Scenario: Adding bookmarks to closed collections (Issue 3083) Given I am logged in as "moderator" @@ -291,7 +291,7 @@ Scenario: Adding bookmarks to closed collections (Issue 3083) And I fill in "bookmark_collection_names" with "rescue_911" And I press "Create" Then I should see "Sorry! We couldn't save this bookmark because:" - And I should see "The collection rescue_911 is not currently open." + And I should see "The collection Rescue 911 is not currently open." When I view the work "Hooray for Homicide" And I follow "Bookmark" And I press "Create" @@ -304,7 +304,7 @@ Scenario: Adding bookmarks to closed collections (Issue 3083) When I follow "Edit" And I fill in "bookmark_collection_names" with "rescue_911" And I press "Update" - Then I should see "We couldn't add your submission to the following collections: Rescue 911 is closed to new submissions." + Then I should see "Sorry! We couldn't save this bookmark because: The collection Rescue 911 is not currently open." # Create a collection, put a bookmark in it, close the collection, then try # to edit that bookmark When I open the collection with the title "Rescue 911" diff --git a/features/collections/collection_anonymity.feature b/features/collections/collection_anonymity.feature index 3c6a9e7a2f1..b7d9e4d805a 100755 --- a/features/collections/collection_anonymity.feature +++ b/features/collections/collection_anonymity.feature @@ -370,10 +370,12 @@ Feature: Collection And I press "Preview" Then I should see "Anonymous Collection" And I should see "Anonymous [creator]" + When I follow "Cancel" - # This is not the desired behavior (AO3-5556), but we want to make sure it doesn't get broken worse - Then I should see "Anonymous Collection" - And I should see "Anonymous [creator]" + And I view the work "My Work" + + Then I should not see "Anonymous Collection" + And I should not see "Anonymous [creator]" Scenario: When an anonymous collection is deleted, works in the collection stop being anonymous. Given I have an anonymous collection "Anonymous Collection" diff --git a/features/collections/collection_invite.feature b/features/collections/collection_invite.feature index 6ab377ff96f..e115cd215f4 100644 --- a/features/collections/collection_invite.feature +++ b/features/collections/collection_invite.feature @@ -99,6 +99,8 @@ Feature: Collection And I invite the work "Over the Limit" to the collection "Favorites" Then I should see "This work has been invited to your collection (Favorites)." When I am logged in as "sky" + # Make sure the cache will expire when accepting the invitation: + And it is currently 1 second from now And "sky" accepts the invitation for their work in the collection "Favorites" And I submit Then I should see "Collection status updated!" diff --git a/features/collections/work_preview_collections.feature b/features/collections/work_preview_collections.feature new file mode 100644 index 00000000000..874157b29ae --- /dev/null +++ b/features/collections/work_preview_collections.feature @@ -0,0 +1,54 @@ +Feature: Previewing collection changes on works + Background: + Given a collection "Assortment" + And I am logged in as a random user + + Scenario: Adding a collection and previewing shows the collection on the preview + Given I post the work "Collectible" + When I edit the work "Collectible" + And I fill in "Post to Collections / Challenges" with "Assortment" + And I press "Preview" + Then I should see "Assortment" + + Scenario: Adding a collection, previewing, and cancelling doesn't add the collection + Given I post the work "Collectible" + When I edit the work "Collectible" + And I fill in "Post to Collections / Challenges" with "Assortment" + And I press "Preview" + And I follow "Cancel" + And I view the work "Collectible" + Then I should not see "Assortment" + + Scenario: Adding a collection, previewing, and updating adds the collection + Given I post the work "Collectible" + When I edit the work "Collectible" + And I fill in "Post to Collections / Challenges" with "Assortment" + And I press "Preview" + And I press "Update" + And I view the work "Collectible" + Then I should see "Assortment" + + Scenario: Removing a collection and previewing hides the collection on the preview + Given I post the work "Collectible" to the collection "Assortment" + When I edit the work "Collectible" + And I fill in "Post to Collections / Challenges" with "" + And I press "Preview" + Then I should not see "Assortment" + + Scenario: Removing a collection, previewing, and cancelling doesn't remove the collection + Given I post the work "Collectible" to the collection "Assortment" + When I edit the work "Collectible" + And I fill in "Post to Collections / Challenges" with "" + And I press "Preview" + And I follow "Cancel" + And I view the work "Collectible" + Then I should see "Assortment" + + Scenario: Removing a collection, previewing, and updating removes the collection + Given I post the work "Collectible" to the collection "Assortment" + When I edit the work "Collectible" + And I fill in "Post to Collections / Challenges" with "" + And I press "Preview" + And I press "Update" + And I view the work "Collectible" + Then I should not see "Assortment" diff --git a/lib/bookmarkable.rb b/lib/bookmarkable.rb index fd18b2c9016..ddb283fd445 100644 --- a/lib/bookmarkable.rb +++ b/lib/bookmarkable.rb @@ -30,12 +30,13 @@ def update_bookmarker_collections_index return unless respond_to?(:should_update_pseud_and_collection_indexes?) return unless should_update_pseud_and_collection_indexes? - collection_ids = Collection.joins(collection_items: :bookmark).where(collection_items: { - bookmarks: { bookmarkable_id: id }, - item_type: "Bookmark", - user_approval_status: 1, - collection_approval_status: 1 - }).pluck(:id, :parent_id).flatten.uniq.compact + collection_ids = Collection + .joins(:collection_items) + .joins("INNER JOIN bookmarks ON bookmarks.id = collection_items.item_id AND collection_items.item_type = 'Bookmark'") + .where(bookmarks: { bookmarkable_id: id, bookmarkable_type: self.class.name }) + .where(collection_items: { user_approval_status: 1, collection_approval_status: 1 }) + .pluck("collections.id", "collections.parent_id") + .flatten.uniq.compact IndexQueue.enqueue_ids(Collection, collection_ids, :background) end diff --git a/lib/collectible.rb b/lib/collectible.rb deleted file mode 100644 index 650c704755b..00000000000 --- a/lib/collectible.rb +++ /dev/null @@ -1,150 +0,0 @@ -module Collectible - - def self.included(collectible) - collectible.class_eval do - - has_many :collection_items, as: :item, inverse_of: :item - accepts_nested_attributes_for :collection_items, allow_destroy: true - has_many :approved_collection_items, -> { approved_by_both }, class_name: "CollectionItem", as: :item - has_many :user_approved_collection_items, -> { approved_by_user }, class_name: "CollectionItem", as: :item - - has_many :collections, - through: :collection_items, - after_add: :set_visibility, - after_remove: :set_visibility, - before_remove: :destroy_collection_item - has_many :approved_collections, - through: :approved_collection_items, - source: :collection - has_many :user_approved_collections, - through: :user_approved_collection_items, - source: :collection - has_many :rejected_collections, - -> { CollectionItem.rejected_by_user }, - through: :collection_items, - source: :collection - - # Note: this scope includes the items in the children of the specified collection - scope :in_collection, lambda { |collection| - distinct.joins(:approved_collection_items).merge(collection.all_items) - } - - after_destroy :clean_up_collection_items - end - end - - # add collections based on a comma-separated list of names - def collections_to_add=(collection_names) - old_collections = self.collection_items.collect(&:collection_id) - names = trim_collection_names(collection_names) - names.each do |name| - c = Collection.find_by(name: name) - errors.add(:base, ts("We couldn't find the collection %{name}.", name: name)) and return if c.nil? - if c.closed? - errors.add(:base, ts("The collection %{name} is not currently open.", name: name)) and return unless c.user_is_maintainer?(User.current_user) || old_collections.include?(c.id) - end - add_to_collection(c) - end - end - - # remove collections based on an array of ids - def collections_to_remove=(collection_ids) - collection_ids.reject {|id| id.blank?}.map {|id| id.is_a?(String) ? id.strip : id}.each do |id| - c = Collection.find(id) || nil - remove_from_collection(c) - end - end - def collections_to_add; nil; end - def collections_to_remove; nil; end - - def add_to_collection(collection) - if collection && !self.collections.include?(collection) - self.collections << collection - end - end - - def remove_from_collection(collection) - if collection && self.collections.include?(collection) - self.collections -= [collection] - end - end - - private - def trim_collection_names(names) - names.split(',').map{ |name| name.strip }.reject {|name| name.blank?} - end - - public - # Set ALL of an item's collections based on a list of collection names - # Refactored to use collections_to_(add,remove) above so we only have one set of code - # performing the actual add/remove actions - # This method now just does the convenience work of getting the removed ids -- any missing collections - # will be identified - # IMPORTANT: cannot delete all existing collections, or else items in closed collections - # can't be edited - def collection_names=(new_collection_names) - new_names = trim_collection_names(new_collection_names) - remove_ids = self.collections.reject {|c| new_names.include?(c.name)}.collect(&:id) - self.collections_to_add = new_names.join(",") - self.collections_to_remove = remove_ids - end - - # NOTE: better to use collections_to_add/remove above instead for more consistency - def collection_names - @collection_names ? @collection_names : self.collections.collect(&:name).uniq.join(",") - end - - - #### UNREVEALED/ANONYMOUS - - # Set the anonymous/unrevealed status of the collectible based on its collections - # We can't check for user approval because the collection item doesn't exist - # and don't need to because this only gets called when the work is a new record and - # therefore being created by its author - def set_anon_unrevealed - if self.respond_to?(:in_anon_collection) && self.respond_to?(:in_unrevealed_collection) - # if we have collection items saved here then the collectible is not a new object - if self.id.nil? || self.collection_items.empty? - self.in_anon_collection = !self.collections.select(&:anonymous?).empty? - self.in_unrevealed_collection = !self.collections.select(&:unrevealed?).empty? - else - update_anon_unrevealed - end - end - return true - end - - # TODO: need a better, DRY, long-term fix - # Collection items can be revealed independently of a collection, so we don't want - # to check the collection status when those are updated - # Only include collections approved by the user - def update_anon_unrevealed - if self.respond_to?(:in_anon_collection) && self.respond_to?(:in_unrevealed_collection) - self.in_anon_collection = self.user_approved_collection_items.anonymous.any? - self.in_unrevealed_collection = self.user_approved_collection_items.unrevealed.any? - end - end - - #### CALLBACKS - - # Calculate (but don't save) whether this work should be anonymous and/or - # unrevealed. Saving the results of this will be handled when the work saves, - # or by the collection item's callbacks. - def set_visibility(collection) - set_anon_unrevealed - end - - # We want to do this after the work is deleted to avoid issues with - # accidentally trying to reveal the work during deletion (which wouldn't - # successfully reveal the work because it'd fail while trying to save the - # partially invalid work, but would cause an error). - def clean_up_collection_items - self.collection_items.destroy_all - end - - # Destroy the collection item before the collection is deleted, so that we - # trigger the CollectionItem's after_destroy callbacks. - def destroy_collection_item(collection) - self.collection_items.find_by(collection: collection).try(:destroy) - end -end diff --git a/spec/controllers/bookmarks_controller_spec.rb b/spec/controllers/bookmarks_controller_spec.rb index cd0b0d815a0..efc6e8ff782 100644 --- a/spec/controllers/bookmarks_controller_spec.rb +++ b/spec/controllers/bookmarks_controller_spec.rb @@ -217,10 +217,7 @@ it "shows the collection warning message" do put :update, params: { id: bookmark.id, bookmark: { collection_names: collection.name } } - success_msg = "Bookmark was successfully updated. " \ - "Added to collection(s): #{collection.title}. " \ - "Please note: private bookmarks are not listed in collections." - it_redirects_to_with_notice(bookmark_path(bookmark), success_msg) + it_redirects_to_with_notice(bookmark_path(bookmark), "Bookmark was successfully updated.") end end end diff --git a/spec/controllers/collections_controller_spec.rb b/spec/controllers/collections_controller_spec.rb index c2e9c3c0457..ca65936276c 100644 --- a/spec/controllers/collections_controller_spec.rb +++ b/spec/controllers/collections_controller_spec.rb @@ -39,7 +39,7 @@ let!(:participant) { create(:collection_participant, collection: prompt_meme_collection) } let!(:moderator) { create(:collection_participant, participant_role: CollectionParticipant::MODERATOR, collection: prompt_meme_collection) } let!(:item) do - create(:collection_item, user_approval_status: "approved", collection_approval_status: "approved", work: create(:work, restricted: false), collection: prompt_meme_collection) + create(:collection_item, user_approval_status: "approved", collection_approval_status: "approved", item: create(:work, restricted: false), collection: prompt_meme_collection) end before do diff --git a/spec/lib/tasks/after_tasks.rake_spec.rb b/spec/lib/tasks/after_tasks.rake_spec.rb index b90f64ac3c9..7fd698e6597 100644 --- a/spec/lib/tasks/after_tasks.rake_spec.rb +++ b/spec/lib/tasks/after_tasks.rake_spec.rb @@ -687,7 +687,7 @@ it "tags the collection with the work's fandoms" do subject.invoke - expect(collection.tags).to include(*items.flat_map(&:fandoms)) + expect(collection.reload.tags).to include(*items.flat_map(&:fandoms)) end shared_examples "does not tag the collection" do @@ -728,6 +728,7 @@ it "tags the collection with the bookmark's AND bookmarked item's fandoms" do subject.invoke + collection.reload expect(collection.tags).to include(*items.flat_map(&:fandoms)) expect(collection.tags).to include(*items.flat_map(&:bookmarkable).flat_map(&:fandoms)) end @@ -778,6 +779,7 @@ it "includes the bookmark's and series's fandoms" do subject.invoke + collection.reload expect(collection.tags).to include(*bookmark.fandoms) expect(collection.tags).to include(*items.flat_map(&:bookmarkable).flat_map { |s| s.work_tags.where(type: "Fandom") }) end diff --git a/spec/lib/works_owner_spec.rb b/spec/lib/works_owner_spec.rb index e03384ab6ae..5c4ef9088cd 100644 --- a/spec/lib/works_owner_spec.rb +++ b/spec/lib/works_owner_spec.rb @@ -67,8 +67,6 @@ shared_examples_for "an owner collection" do it "should change after a new work is created" do FactoryBot.create(:work, collection_names: @owner.name) - @owner.collection_items.each {|ci| ci.approve(nil); ci.save} - @child.collection_items.each {|ci| ci.approve(nil); ci.save} if @child expect(@original_cache_key).not_to eq(@owner.works_index_cache_key) end end @@ -126,11 +124,6 @@ travel_to(10.minutes.ago) @owner = FactoryBot.create(:collection) @work = FactoryBot.create(:work, collection_names: @owner.name) - - # we have to approve the collection items before we get a change in - # the cache key, since it uses approved works - @owner.collection_items.each {|ci| ci.approve(nil); ci.save} - @original_cache_key = @owner.works_index_cache_key travel_back end @@ -150,7 +143,6 @@ @owner.reload @work1 = @work @work = FactoryBot.create(:work, collection_names: @child.name) - @child.collection_items.each {|ci| ci.approve(nil); ci.save} @original_cache_key = @owner.works_index_cache_key travel_back end @@ -175,7 +167,6 @@ before do travel_to(1.second.from_now) @work2 = FactoryBot.create(:work, fandom_string: @fandom.name, collection_names: @owner.name) - @owner.collection_items.each {|ci| ci.approve(nil); ci.save} travel_back end @@ -190,7 +181,6 @@ @fandom2 = FactoryBot.create(:fandom) travel_to(1.second.from_now) @work2 = FactoryBot.create(:work, fandom_string: @fandom2.name, collection_names: @owner.name) - @owner.collection_items.each { |ci| ci.approve(nil); ci.save } travel_back end diff --git a/spec/models/collection_item_spec.rb b/spec/models/collection_item_spec.rb index e626f55263f..fb05b38f8bc 100644 --- a/spec/models/collection_item_spec.rb +++ b/spec/models/collection_item_spec.rb @@ -178,4 +178,15 @@ end end end + + describe "#unmark_for_destruction" do + let(:collection_item) { create(:collection_item) } + + it "changes marked_for_destruction? to false" do + collection_item.mark_for_destruction + expect(collection_item.marked_for_destruction?).to be_truthy + collection_item.unmark_for_destruction + expect(collection_item.marked_for_destruction?).to be_falsey + end + end end diff --git a/spec/lib/collectible_spec.rb b/spec/models/concerns/collectible_spec.rb similarity index 96% rename from spec/lib/collectible_spec.rb rename to spec/models/concerns/collectible_spec.rb index 48236b84c18..ef11cdfb728 100644 --- a/spec/lib/collectible_spec.rb +++ b/spec/models/concerns/collectible_spec.rb @@ -14,9 +14,8 @@ def update_collection_setting(collection, setting, value) fake_name = "blah_blah_blah_not_an_existing_name" work = create(:work) work.collection_names = fake_name - expect(work.errors[:base].first).to match("find") # use a very basic part of the error message - expect(work.errors[:base].first).to match(fake_name) - expect(work.save).to be_truthy + expect(work.save).to be_falsey + expect(work.errors.full_messages).to include("We couldn't find the collection #{fake_name}.") work.reload expect(work.collection_names).not_to include(fake_name) end diff --git a/spec/models/search/collection_query_spec.rb b/spec/models/search/collection_query_spec.rb index 0672c7c60df..645df9e6d38 100644 --- a/spec/models/search/collection_query_spec.rb +++ b/spec/models/search/collection_query_spec.rb @@ -56,10 +56,10 @@ let!(:participant) { create(:collection_participant, collection: prompt_meme_collection) } let!(:moderator) { create(:collection_participant, participant_role: CollectionParticipant::MODERATOR, collection: prompt_meme_collection) } let!(:item) do - create(:collection_item, user_approval_status: "approved", collection_approval_status: "approved", work: create(:work, restricted: false), collection: prompt_meme_collection) + create(:collection_item, user_approval_status: "approved", collection_approval_status: "approved", item: create(:work, restricted: false), collection: prompt_meme_collection) end let!(:item2) do - create(:collection_item, user_approval_status: "approved", collection_approval_status: "approved", work: create(:work, restricted: true), collection: gift_exchange_collection) + create(:collection_item, user_approval_status: "approved", collection_approval_status: "approved", item: create(:work, restricted: true), collection: gift_exchange_collection) end before do