mirror of
https://github.com/discourse/discourse.git
synced 2024-11-25 23:33:37 +08:00
3e4621c2cb
This pull request follows on from https://github.com/discourse/discourse/pull/16308. This one does the following:
* Changes `BookmarkQuery` to allow for querying more than just Post and Topic bookmarkables
* Introduces a `Bookmark.register_bookmarkable` method which requires a model, serializer, fields and preload includes for searching. These registered `Bookmarkable` types are then used when validating new bookmarks, and also when determining which serializer to use for the bookmark list. The `Post` and `Topic` bookmarkables are registered by default.
* Adds new specific types for Post and Topic bookmark serializers along with preloading of associations in `UserBookmarkList`
* Changes to the user bookmark list template to allow for more generic bookmarkable types alongside the Post and Topic ones which need to display in a particular way
All of these changes are gated behind the `use_polymorphic_bookmarks` site setting, apart from the .hbs changes where I have updated the original `UserBookmarkSerializer` with some stub methods.
Following this PR will be several plugin PRs (for assign, chat, encrypt) that will register their own bookmarkable types or otherwise alter the bookmark serializers in their own way, also gated behind `use_polymorphic_bookmarks`.
This commit also removes `BookmarkQuery.preloaded_custom_fields` and the functionality surrounding it. It was added in 0cd502a558
but only used by one plugin (discourse-assign) where it has since been removed, and is now used by no plugins. We don't need it anymore.
339 lines
12 KiB
Ruby
339 lines
12 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class Bookmark < ActiveRecord::Base
|
|
# these columns were here for a very short amount of time,
|
|
# hence the very short ignore time
|
|
self.ignored_columns = [
|
|
"topic_id", # TODO 2022-04-01: remove
|
|
"reminder_type" # TODO 2021-04-01: remove
|
|
]
|
|
|
|
cattr_accessor :registered_bookmarkables
|
|
self.registered_bookmarkables = []
|
|
|
|
def self.registered_bookmarkable_from_type(type)
|
|
Bookmark.registered_bookmarkables.find { |bm| bm.model.name == type }
|
|
end
|
|
|
|
def self.register_bookmarkable(
|
|
model:, serializer:, list_query:, search_query:, preload_associations: []
|
|
)
|
|
Bookmark.registered_bookmarkables << Bookmarkable.new(
|
|
model: model,
|
|
serializer: serializer,
|
|
list_query: list_query,
|
|
search_query: search_query,
|
|
preload_associations: preload_associations
|
|
)
|
|
end
|
|
|
|
##
|
|
# This is called when the app loads, similar to AdminDashboardData.reset_problem_checks,
|
|
# so the default Post and Topic bookmarkables are registered on
|
|
# boot.
|
|
#
|
|
# This method also can be used in testing to reset bookmarkables between
|
|
# tests. It will also fire multiple times in development mode because
|
|
# classes are not cached.
|
|
def self.reset_bookmarkables
|
|
self.registered_bookmarkables = []
|
|
Bookmark.register_bookmarkable(
|
|
model: Post,
|
|
serializer: UserPostBookmarkSerializer,
|
|
list_query: lambda do |user, guardian|
|
|
topics = Topic.listable_topics.secured(guardian)
|
|
pms = Topic.private_messages_for_user(user)
|
|
post_bookmarks = user
|
|
.bookmarks_of_type("Post")
|
|
.joins("INNER JOIN posts ON posts.id = bookmarks.bookmarkable_id AND bookmarks.bookmarkable_type = 'Post'")
|
|
.joins("LEFT JOIN topics ON topics.id = posts.topic_id")
|
|
.joins("LEFT JOIN topic_users ON topic_users.topic_id = topics.id")
|
|
.where("topic_users.user_id = ?", user.id)
|
|
guardian.filter_allowed_categories(
|
|
post_bookmarks.merge(topics.or(pms)).merge(Post.secured(guardian))
|
|
)
|
|
end,
|
|
search_query: lambda do |bookmarks, query, ts_query, &bookmarkable_search|
|
|
bookmarkable_search.call(
|
|
bookmarks.joins(
|
|
"LEFT JOIN post_search_data ON post_search_data.post_id = bookmarks.bookmarkable_id AND bookmarks.bookmarkable_type = 'Post'"
|
|
),
|
|
"#{ts_query} @@ post_search_data.search_data"
|
|
)
|
|
end,
|
|
preload_associations: [{ topic: [:topic_users, :tags] }, :user]
|
|
)
|
|
Bookmark.register_bookmarkable(
|
|
model: Topic,
|
|
serializer: UserTopicBookmarkSerializer,
|
|
list_query: lambda do |user, guardian|
|
|
topics = Topic.listable_topics.secured(guardian)
|
|
pms = Topic.private_messages_for_user(user)
|
|
topic_bookmarks = user
|
|
.bookmarks_of_type("Topic")
|
|
.joins("INNER JOIN topics ON topics.id = bookmarks.bookmarkable_id AND bookmarks.bookmarkable_type = 'Topic'")
|
|
.joins("LEFT JOIN topic_users ON topic_users.topic_id = topics.id")
|
|
.where("topic_users.user_id = ?", user.id)
|
|
guardian.filter_allowed_categories(topic_bookmarks.merge(topics.or(pms)))
|
|
end,
|
|
search_query: lambda do |bookmarks, query, ts_query, &bookmarkable_search|
|
|
bookmarkable_search.call(
|
|
bookmarks
|
|
.joins("LEFT JOIN posts ON posts.topic_id = topics.id AND posts.post_number = 1")
|
|
.joins("LEFT JOIN post_search_data ON post_search_data.post_id = posts.id"),
|
|
"#{ts_query} @@ post_search_data.search_data"
|
|
)
|
|
end,
|
|
preload_associations: [:topic_users, :tags, { posts: :user }]
|
|
)
|
|
end
|
|
reset_bookmarkables
|
|
|
|
def self.valid_bookmarkable_types
|
|
Bookmark.registered_bookmarkables.map(&:model).map(&:to_s)
|
|
end
|
|
|
|
belongs_to :user
|
|
belongs_to :post
|
|
has_one :topic, through: :post
|
|
belongs_to :bookmarkable, polymorphic: true
|
|
|
|
# TODO (martin) [POLYBOOK] Not relevant once polymorphic bookmarks are implemented.
|
|
def topic_id
|
|
return if SiteSetting.use_polymorphic_bookmarks
|
|
post.topic_id
|
|
end
|
|
|
|
def self.auto_delete_preferences
|
|
@auto_delete_preferences ||= Enum.new(
|
|
never: 0,
|
|
when_reminder_sent: 1,
|
|
on_owner_reply: 2,
|
|
clear_reminder: 3,
|
|
)
|
|
end
|
|
|
|
def self.select_type(bookmarks_relation, type)
|
|
bookmarks_relation.select { |bm| bm.bookmarkable_type == type }
|
|
end
|
|
|
|
# TODO (martin) [POLYBOOK] Not relevant once polymorphic bookmarks are implemented.
|
|
validate :unique_per_post_for_user,
|
|
on: [:create, :update],
|
|
if: Proc.new { |b| b.will_save_change_to_post_id? || b.will_save_change_to_user_id? }
|
|
|
|
# TODO (martin) [POLYBOOK] Not relevant once polymorphic bookmarks are implemented.
|
|
validate :for_topic_must_use_first_post,
|
|
on: [:create, :update],
|
|
if: Proc.new { |b| b.will_save_change_to_post_id? || b.will_save_change_to_for_topic? }
|
|
|
|
validate :polymorphic_columns_present, on: [:create, :update]
|
|
validate :valid_bookmarkable_type, on: [:create, :update]
|
|
|
|
validate :unique_per_bookmarkable,
|
|
on: [:create, :update],
|
|
if: Proc.new { |b|
|
|
b.will_save_change_to_bookmarkable_id? || b.will_save_change_to_bookmarkable_type? || b.will_save_change_to_user_id?
|
|
}
|
|
|
|
validate :ensure_sane_reminder_at_time, if: :will_save_change_to_reminder_at?
|
|
validate :bookmark_limit_not_reached
|
|
validates :name, length: { maximum: 100 }
|
|
|
|
def polymorphic_columns_present
|
|
return if !SiteSetting.use_polymorphic_bookmarks
|
|
return if self.bookmarkable_id.present? && self.bookmarkable_type.present?
|
|
|
|
self.errors.add(:base, I18n.t("bookmarks.errors.bookmarkable_id_type_required"))
|
|
end
|
|
|
|
def unique_per_bookmarkable
|
|
return if !SiteSetting.use_polymorphic_bookmarks
|
|
return if !Bookmark.exists?(user_id: user_id, bookmarkable_id: bookmarkable_id, bookmarkable_type: bookmarkable_type)
|
|
|
|
self.errors.add(:base, I18n.t("bookmarks.errors.already_bookmarked", type: bookmarkable_type))
|
|
end
|
|
|
|
# TODO (martin) [POLYBOOK] Not relevant once polymorphic bookmarks are implemented.
|
|
def unique_per_post_for_user
|
|
return if SiteSetting.use_polymorphic_bookmarks
|
|
|
|
exists = if is_for_first_post?
|
|
Bookmark.exists?(user_id: user_id, post_id: post_id, for_topic: for_topic)
|
|
else
|
|
Bookmark.exists?(user_id: user_id, post_id: post_id)
|
|
end
|
|
|
|
if exists
|
|
self.errors.add(:base, I18n.t("bookmarks.errors.already_bookmarked_post"))
|
|
end
|
|
end
|
|
|
|
# TODO (martin) [POLYBOOK] Not relevant once polymorphic bookmarks are implemented.
|
|
def for_topic_must_use_first_post
|
|
if !is_for_first_post? && self.for_topic
|
|
self.errors.add(:base, I18n.t("bookmarks.errors.for_topic_must_use_first_post"))
|
|
end
|
|
end
|
|
|
|
def ensure_sane_reminder_at_time
|
|
return if reminder_at.blank?
|
|
if reminder_at < Time.zone.now
|
|
self.errors.add(:base, I18n.t("bookmarks.errors.cannot_set_past_reminder"))
|
|
end
|
|
if reminder_at > 10.years.from_now.utc
|
|
self.errors.add(:base, I18n.t("bookmarks.errors.cannot_set_reminder_in_distant_future"))
|
|
end
|
|
end
|
|
|
|
def bookmark_limit_not_reached
|
|
return if user.bookmarks.count < SiteSetting.max_bookmarks_per_user
|
|
return if !new_record?
|
|
|
|
self.errors.add(
|
|
:base,
|
|
I18n.t(
|
|
"bookmarks.errors.too_many",
|
|
user_bookmarks_url: "#{Discourse.base_url}/my/activity/bookmarks",
|
|
limit: SiteSetting.max_bookmarks_per_user
|
|
)
|
|
)
|
|
end
|
|
|
|
def valid_bookmarkable_type
|
|
return if !SiteSetting.use_polymorphic_bookmarks
|
|
return if Bookmark.valid_bookmarkable_types.include?(self.bookmarkable_type)
|
|
|
|
self.errors.add(:base, I18n.t("bookmarks.errors.invalid_bookmarkable", type: self.bookmarkable_type))
|
|
end
|
|
|
|
# TODO (martin) [POLYBOOK] Not relevant once polymorphic bookmarks are implemented.
|
|
def is_for_first_post?
|
|
@is_for_first_post ||= new_record? ? Post.exists?(id: post_id, post_number: 1) : post.post_number == 1
|
|
end
|
|
|
|
def auto_delete_when_reminder_sent?
|
|
self.auto_delete_preference == Bookmark.auto_delete_preferences[:when_reminder_sent]
|
|
end
|
|
|
|
# TODO (martin) [POLYBOOK] This is only relevant for post/topic bookmarkables, need to
|
|
# think of a way to do this gracefully.
|
|
def auto_delete_on_owner_reply?
|
|
self.auto_delete_preference == Bookmark.auto_delete_preferences[:on_owner_reply]
|
|
end
|
|
|
|
def auto_clear_reminder_when_reminder_sent?
|
|
self.auto_delete_preference == Bookmark.auto_delete_preferences[:clear_reminder]
|
|
end
|
|
|
|
def reminder_at_ics(offset: 0)
|
|
(reminder_at + offset).strftime(I18n.t("datetime_formats.formats.calendar_ics"))
|
|
end
|
|
|
|
def clear_reminder!
|
|
update!(
|
|
reminder_last_sent_at: Time.zone.now,
|
|
reminder_set_at: nil,
|
|
)
|
|
end
|
|
|
|
scope :with_reminders, -> do
|
|
where("reminder_at IS NOT NULL")
|
|
end
|
|
|
|
scope :pending_reminders, ->(before_time = Time.now.utc) do
|
|
with_reminders.where("reminder_at <= ?", before_time).where(reminder_last_sent_at: nil)
|
|
end
|
|
|
|
scope :pending_reminders_for_user, ->(user) do
|
|
pending_reminders.where(user: user)
|
|
end
|
|
|
|
scope :for_user_in_topic, ->(user_id, topic_id) {
|
|
if SiteSetting.use_polymorphic_bookmarks
|
|
joins("LEFT JOIN posts ON posts.id = bookmarks.bookmarkable_id AND bookmarks.bookmarkable_type = 'Post'")
|
|
.joins("LEFT JOIN topics ON topics.id = bookmarks.bookmarkable_id AND bookmarks.bookmarkable_type = 'Topic'")
|
|
.where(
|
|
"bookmarks.user_id = :user_id AND (topics.id = :topic_id OR posts.topic_id = :topic_id)",
|
|
user_id: user_id, topic_id: topic_id
|
|
)
|
|
else
|
|
joins(:post).where(user_id: user_id, posts: { topic_id: topic_id })
|
|
end
|
|
}
|
|
|
|
def self.find_for_topic_by_user(topic_id, user_id)
|
|
if SiteSetting.use_polymorphic_bookmarks
|
|
find_by(user_id: user_id, bookmarkable_id: topic_id, bookmarkable_type: "Topic")
|
|
else
|
|
for_user_in_topic(user_id, topic_id).where(for_topic: true).first
|
|
end
|
|
end
|
|
|
|
def self.count_per_day(opts = nil)
|
|
opts ||= {}
|
|
result = where('bookmarks.created_at >= ?', opts[:start_date] || (opts[:since_days_ago] || 30).days.ago)
|
|
|
|
if opts[:end_date]
|
|
result = result.where('bookmarks.created_at <= ?', opts[:end_date])
|
|
end
|
|
|
|
if opts[:category_id]
|
|
result = result.joins(:topic).merge(Topic.in_category_and_subcategories(opts[:category_id]))
|
|
end
|
|
|
|
result.group('date(bookmarks.created_at)')
|
|
.order('date(bookmarks.created_at)')
|
|
.count
|
|
end
|
|
|
|
##
|
|
# Deletes bookmarks that are attached to posts/topics that were deleted
|
|
# more than X days ago. We don't delete bookmarks instantly when a post/topic
|
|
# is deleted so that there is a grace period to un-delete.
|
|
def self.cleanup!
|
|
grace_time = 3.days.ago
|
|
topics_deleted = DB.query(<<~SQL, grace_time: grace_time)
|
|
DELETE FROM bookmarks b
|
|
USING topics t, posts p
|
|
WHERE (t.id = p.topic_id AND b.post_id = p.id)
|
|
AND (t.deleted_at < :grace_time OR p.deleted_at < :grace_time)
|
|
RETURNING t.id AS topic_id
|
|
SQL
|
|
|
|
topics_deleted_ids = topics_deleted.map(&:topic_id).uniq
|
|
topics_deleted_ids.each do |topic_id|
|
|
Jobs.enqueue(:sync_topic_user_bookmarked, topic_id: topic_id)
|
|
end
|
|
end
|
|
end
|
|
|
|
# == Schema Information
|
|
#
|
|
# Table name: bookmarks
|
|
#
|
|
# id :bigint not null, primary key
|
|
# user_id :bigint not null
|
|
# post_id :bigint
|
|
# name :string(100)
|
|
# reminder_at :datetime
|
|
# created_at :datetime not null
|
|
# updated_at :datetime not null
|
|
# reminder_last_sent_at :datetime
|
|
# reminder_set_at :datetime
|
|
# auto_delete_preference :integer default(0), not null
|
|
# pinned :boolean default(FALSE)
|
|
# for_topic :boolean default(FALSE), not null
|
|
# bookmarkable_id :integer
|
|
# bookmarkable_type :string
|
|
#
|
|
# Indexes
|
|
#
|
|
# idx_bookmarks_user_polymorphic_unique (user_id,bookmarkable_type,bookmarkable_id) UNIQUE
|
|
# index_bookmarks_on_post_id (post_id)
|
|
# index_bookmarks_on_reminder_at (reminder_at)
|
|
# index_bookmarks_on_reminder_set_at (reminder_set_at)
|
|
# index_bookmarks_on_user_id (user_id)
|
|
# index_bookmarks_on_user_id_and_post_id_and_for_topic (user_id,post_id,for_topic) UNIQUE
|
|
#
|