2242 lines
71 KiB
Ruby
2242 lines
71 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class Topic < ActiveRecord::Base
|
|
class UserExists < StandardError
|
|
end
|
|
|
|
class NotAllowed < StandardError
|
|
end
|
|
include RateLimiter::OnCreateRecord
|
|
include HasCustomFields
|
|
include Trashable
|
|
include Searchable
|
|
include LimitedEdit
|
|
extend Forwardable
|
|
|
|
EXTERNAL_ID_MAX_LENGTH = 50
|
|
|
|
self.ignored_columns = [
|
|
"avg_time", # TODO: Remove when 20240212034010_drop_deprecated_columns has been promoted to pre-deploy
|
|
"image_url", # TODO: Remove when 20240212034010_drop_deprecated_columns has been promoted to pre-deploy
|
|
]
|
|
|
|
def_delegator :featured_users, :user_ids, :featured_user_ids
|
|
def_delegator :featured_users, :choose, :feature_topic_users
|
|
|
|
def_delegator :notifier, :watch!, :notify_watch!
|
|
def_delegator :notifier, :track!, :notify_tracking!
|
|
def_delegator :notifier, :regular!, :notify_regular!
|
|
def_delegator :notifier, :mute!, :notify_muted!
|
|
def_delegator :notifier, :toggle_mute, :toggle_mute
|
|
|
|
attr_accessor :allowed_user_ids, :allowed_group_ids, :tags_changed, :includes_destination_category
|
|
|
|
def self.max_fancy_title_length
|
|
400
|
|
end
|
|
|
|
def self.share_thumbnail_size
|
|
[1024, 1024]
|
|
end
|
|
|
|
def self.thumbnail_sizes
|
|
[self.share_thumbnail_size] + DiscoursePluginRegistry.topic_thumbnail_sizes
|
|
end
|
|
|
|
def self.visibility_reasons
|
|
@visible_reasons ||=
|
|
Enum.new(
|
|
op_flag_threshold_reached: 0,
|
|
op_unhidden: 1,
|
|
embedded_topic: 2,
|
|
manually_unlisted: 3,
|
|
manually_relisted: 4,
|
|
bulk_action: 5,
|
|
unknown: 99,
|
|
)
|
|
end
|
|
|
|
def shared_draft?
|
|
SharedDraft.exists?(topic_id: id)
|
|
end
|
|
|
|
def thumbnail_job_redis_key(sizes)
|
|
"generate_topic_thumbnail_enqueue_#{id}_#{sizes.inspect}"
|
|
end
|
|
|
|
def filtered_topic_thumbnails(extra_sizes: [])
|
|
return nil unless original = image_upload
|
|
return nil unless original.read_attribute(:width) && original.read_attribute(:height)
|
|
|
|
thumbnail_sizes = Topic.thumbnail_sizes + extra_sizes
|
|
topic_thumbnails.filter do |record|
|
|
thumbnail_sizes.include?([record.max_width, record.max_height])
|
|
end
|
|
end
|
|
|
|
def thumbnail_info(enqueue_if_missing: false, extra_sizes: [])
|
|
return nil unless original = image_upload
|
|
return nil if original.filesize >= SiteSetting.max_image_size_kb.to_i.kilobytes
|
|
return nil unless original.read_attribute(:width) && original.read_attribute(:height)
|
|
|
|
infos = []
|
|
infos << { # Always add original
|
|
max_width: nil,
|
|
max_height: nil,
|
|
width: original.width,
|
|
height: original.height,
|
|
url: original.url,
|
|
}
|
|
|
|
records = filtered_topic_thumbnails(extra_sizes: extra_sizes)
|
|
|
|
records.each do |record|
|
|
next unless record.optimized_image # Only serialize successful thumbnails
|
|
|
|
infos << {
|
|
max_width: record.max_width,
|
|
max_height: record.max_height,
|
|
width: record.optimized_image&.width,
|
|
height: record.optimized_image&.height,
|
|
url: record.optimized_image&.url,
|
|
}
|
|
end
|
|
|
|
thumbnail_sizes = Topic.thumbnail_sizes + extra_sizes
|
|
if SiteSetting.create_thumbnails && enqueue_if_missing &&
|
|
records.length < thumbnail_sizes.length &&
|
|
Discourse.redis.set(thumbnail_job_redis_key(extra_sizes), 1, nx: true, ex: 1.minute)
|
|
Jobs.enqueue(:generate_topic_thumbnails, { topic_id: id, extra_sizes: extra_sizes })
|
|
end
|
|
|
|
infos.each { |i| i[:url] = UrlHelper.cook_url(i[:url], secure: original.secure?, local: true) }
|
|
|
|
infos.sort_by! { |i| -i[:width] * i[:height] }
|
|
end
|
|
|
|
def generate_thumbnails!(extra_sizes: [])
|
|
return nil unless SiteSetting.create_thumbnails
|
|
return nil unless original = image_upload
|
|
return nil if original.filesize >= SiteSetting.max_image_size_kb.kilobytes
|
|
return nil unless original.width && original.height
|
|
extra_sizes = [] unless extra_sizes.kind_of?(Array)
|
|
|
|
(Topic.thumbnail_sizes + extra_sizes).each do |dim|
|
|
TopicThumbnail.find_or_create_for!(original, max_width: dim[0], max_height: dim[1])
|
|
end
|
|
end
|
|
|
|
def image_url(enqueue_if_missing: false)
|
|
thumbnail =
|
|
topic_thumbnails.detect do |record|
|
|
record.max_width == Topic.share_thumbnail_size[0] &&
|
|
record.max_height == Topic.share_thumbnail_size[1]
|
|
end
|
|
|
|
if thumbnail.nil? && image_upload && SiteSetting.create_thumbnails &&
|
|
image_upload.filesize < SiteSetting.max_image_size_kb.kilobytes &&
|
|
image_upload.read_attribute(:width) && image_upload.read_attribute(:height) &&
|
|
enqueue_if_missing &&
|
|
Discourse.redis.set(thumbnail_job_redis_key([]), 1, nx: true, ex: 1.minute)
|
|
Jobs.enqueue(:generate_topic_thumbnails, { topic_id: id })
|
|
end
|
|
|
|
raw_url = thumbnail&.optimized_image&.url || image_upload&.url
|
|
UrlHelper.cook_url(raw_url, secure: image_upload&.secure?, local: true) if raw_url
|
|
end
|
|
|
|
def featured_users
|
|
@featured_users ||= TopicFeaturedUsers.new(self)
|
|
end
|
|
|
|
def trash!(trashed_by = nil)
|
|
trigger_event = false
|
|
|
|
if deleted_at.nil?
|
|
update_category_topic_count_by(-1) if visible?
|
|
CategoryTagStat.topic_deleted(self) if self.tags.present?
|
|
trigger_event = true
|
|
end
|
|
|
|
super(trashed_by)
|
|
|
|
DiscourseEvent.trigger(:topic_trashed, self) if trigger_event
|
|
|
|
self.topic_embed.trash! if has_topic_embed?
|
|
end
|
|
|
|
def recover!(recovered_by = nil)
|
|
trigger_event = false
|
|
|
|
unless deleted_at.nil?
|
|
update_category_topic_count_by(1) if visible?
|
|
CategoryTagStat.topic_recovered(self) if self.tags.present?
|
|
trigger_event = true
|
|
end
|
|
|
|
# Note parens are required because superclass doesn't take `recovered_by`
|
|
super()
|
|
|
|
DiscourseEvent.trigger(:topic_recovered, self) if trigger_event
|
|
|
|
unless (topic_embed = TopicEmbed.with_deleted.find_by_topic_id(id)).nil?
|
|
topic_embed.recover!
|
|
end
|
|
end
|
|
|
|
rate_limit :default_rate_limiter
|
|
rate_limit :limit_topics_per_day
|
|
rate_limit :limit_private_messages_per_day
|
|
|
|
validates :title,
|
|
if: Proc.new { |t| t.new_record? || t.title_changed? || t.category_id_changed? },
|
|
presence: true,
|
|
topic_title_length: true,
|
|
censored_words: true,
|
|
watched_words: true,
|
|
quality_title: {
|
|
unless: :private_message?,
|
|
},
|
|
max_emojis: true,
|
|
unique_among: {
|
|
unless:
|
|
Proc.new { |t| (SiteSetting.allow_duplicate_topic_titles? || t.private_message?) },
|
|
message: :has_already_been_used,
|
|
allow_blank: true,
|
|
case_sensitive: false,
|
|
collection:
|
|
Proc.new { |t|
|
|
if SiteSetting.allow_duplicate_topic_titles_category?
|
|
Topic.listable_topics.where("category_id = ?", t.category_id)
|
|
else
|
|
Topic.listable_topics
|
|
end
|
|
},
|
|
}
|
|
|
|
validates :category_id,
|
|
presence: true,
|
|
exclusion: {
|
|
in: Proc.new { [SiteSetting.uncategorized_category_id] },
|
|
},
|
|
if:
|
|
Proc.new { |t|
|
|
(t.new_record? || t.category_id_changed?) &&
|
|
!SiteSetting.allow_uncategorized_topics && (t.archetype.nil? || t.regular?)
|
|
}
|
|
|
|
validates :featured_link, allow_nil: true, url: true
|
|
validate if: :featured_link do
|
|
if featured_link_changed? && !Guardian.new(user).can_edit_featured_link?(category_id)
|
|
errors.add(:featured_link)
|
|
end
|
|
end
|
|
|
|
validates :external_id,
|
|
allow_nil: true,
|
|
uniqueness: {
|
|
case_sensitive: false,
|
|
},
|
|
length: {
|
|
maximum: EXTERNAL_ID_MAX_LENGTH,
|
|
},
|
|
format: {
|
|
with: /\A[\w-]+\z/,
|
|
}
|
|
|
|
before_validation do
|
|
self.title = TextCleaner.clean_title(TextSentinel.title_sentinel(title).text) if errors[
|
|
:title
|
|
].empty?
|
|
self.featured_link = self.featured_link.strip.presence if self.featured_link
|
|
end
|
|
|
|
belongs_to :category
|
|
has_many :category_users, through: :category
|
|
has_many :posts
|
|
|
|
# NOTE: To get all Post _and_ Topic bookmarks for a topic by user,
|
|
# use the Bookmark.for_user_in_topic scope.
|
|
has_many :bookmarks, as: :bookmarkable
|
|
|
|
has_many :ordered_posts, -> { order(post_number: :asc) }, class_name: "Post"
|
|
has_many :topic_allowed_users
|
|
has_many :topic_allowed_groups
|
|
has_many :incoming_email
|
|
|
|
has_many :group_archived_messages, dependent: :destroy
|
|
has_many :user_archived_messages, dependent: :destroy
|
|
has_many :topic_view_stats, dependent: :destroy
|
|
|
|
has_many :allowed_groups, through: :topic_allowed_groups, source: :group
|
|
has_many :allowed_group_users, through: :allowed_groups, source: :users
|
|
has_many :allowed_users, through: :topic_allowed_users, source: :user
|
|
|
|
has_many :topic_tags
|
|
has_many :tags, through: :topic_tags, dependent: :destroy # dependent destroy applies to the topic_tags records
|
|
has_many :tag_users, through: :tags
|
|
|
|
has_one :top_topic
|
|
has_one :shared_draft, dependent: :destroy
|
|
has_one :published_page
|
|
|
|
belongs_to :user
|
|
belongs_to :last_poster, class_name: "User", foreign_key: :last_post_user_id
|
|
belongs_to :featured_user1, class_name: "User", foreign_key: :featured_user1_id
|
|
belongs_to :featured_user2, class_name: "User", foreign_key: :featured_user2_id
|
|
belongs_to :featured_user3, class_name: "User", foreign_key: :featured_user3_id
|
|
belongs_to :featured_user4, class_name: "User", foreign_key: :featured_user4_id
|
|
|
|
has_many :topic_users
|
|
has_many :dismissed_topic_users
|
|
has_many :topic_links
|
|
has_many :topic_invites
|
|
has_many :invites, through: :topic_invites, source: :invite
|
|
has_many :topic_timers, dependent: :destroy
|
|
has_many :reviewables
|
|
has_many :user_profiles
|
|
|
|
has_one :user_warning
|
|
has_one :first_post, -> { where post_number: 1 }, class_name: "Post"
|
|
has_one :topic_search_data
|
|
has_one :topic_embed, dependent: :destroy
|
|
has_one :linked_topic, dependent: :destroy
|
|
|
|
belongs_to :image_upload, class_name: "Upload"
|
|
has_many :topic_thumbnails, through: :image_upload
|
|
|
|
# When we want to temporarily attach some data to a forum topic (usually before serialization)
|
|
attr_accessor :user_data
|
|
attr_accessor :category_user_data
|
|
attr_accessor :dismissed
|
|
|
|
attr_accessor :posters # TODO: can replace with posters_summary once we remove old list code
|
|
attr_accessor :participants
|
|
attr_accessor :participant_groups
|
|
attr_accessor :topic_list
|
|
attr_accessor :include_last_poster
|
|
attr_accessor :import_mode # set to true to optimize creation and save for imports
|
|
|
|
# The regular order
|
|
scope :topic_list_order, -> { order("topics.bumped_at desc") }
|
|
|
|
# Return private message topics
|
|
scope :private_messages, -> { where(archetype: Archetype.private_message) }
|
|
|
|
PRIVATE_MESSAGES_SQL_USER = <<~SQL
|
|
SELECT topic_id
|
|
FROM topic_allowed_users
|
|
WHERE user_id = :user_id
|
|
SQL
|
|
|
|
PRIVATE_MESSAGES_SQL_GROUP = <<~SQL
|
|
SELECT tg.topic_id
|
|
FROM topic_allowed_groups tg
|
|
JOIN group_users gu ON gu.user_id = :user_id AND gu.group_id = tg.group_id
|
|
SQL
|
|
|
|
scope :private_messages_for_user,
|
|
->(user) do
|
|
private_messages.where(
|
|
"topics.id IN (#{PRIVATE_MESSAGES_SQL_USER})
|
|
OR topics.id IN (#{PRIVATE_MESSAGES_SQL_GROUP})",
|
|
user_id: user.id,
|
|
)
|
|
end
|
|
|
|
scope :listable_topics, -> { where("topics.archetype <> ?", Archetype.private_message) }
|
|
|
|
scope :by_newest, -> { order("topics.created_at desc, topics.id desc") }
|
|
|
|
scope :visible, -> { where(visible: true) }
|
|
|
|
scope :created_since, lambda { |time_ago| where("topics.created_at > ?", time_ago) }
|
|
|
|
scope :exclude_scheduled_bump_topics, -> { where.not(id: TopicTimer.scheduled_bump_topics) }
|
|
|
|
scope :secured,
|
|
lambda { |guardian = nil|
|
|
ids = guardian.secure_category_ids if guardian
|
|
|
|
# Query conditions
|
|
condition =
|
|
if ids.present?
|
|
["NOT read_restricted OR id IN (:cats)", cats: ids]
|
|
else
|
|
["NOT read_restricted"]
|
|
end
|
|
|
|
where(
|
|
"topics.category_id IS NULL OR topics.category_id IN (SELECT id FROM categories WHERE #{condition[0]})",
|
|
condition[1],
|
|
)
|
|
}
|
|
|
|
scope :in_category_and_subcategories,
|
|
lambda { |category_id|
|
|
if category_id
|
|
where("topics.category_id IN (?)", Category.subcategory_ids(category_id.to_i))
|
|
end
|
|
}
|
|
|
|
scope :with_subtype, ->(subtype) { where("topics.subtype = ?", subtype) }
|
|
|
|
attr_accessor :ignore_category_auto_close
|
|
attr_accessor :skip_callbacks
|
|
attr_accessor :advance_draft
|
|
|
|
before_create { initialize_default_values }
|
|
|
|
after_create do
|
|
unless skip_callbacks
|
|
changed_to_category(category)
|
|
advance_draft_sequence if advance_draft
|
|
end
|
|
end
|
|
|
|
before_save do
|
|
ensure_topic_has_a_category unless skip_callbacks
|
|
|
|
write_attribute(:fancy_title, Topic.fancy_title(title)) if title_changed?
|
|
|
|
if category_id_changed? || new_record?
|
|
inherit_auto_close_from_category
|
|
inherit_slow_mode_from_category
|
|
end
|
|
end
|
|
|
|
after_save do
|
|
banner = "banner"
|
|
|
|
if archetype_before_last_save == banner || archetype == banner
|
|
ApplicationController.banner_json_cache.clear
|
|
end
|
|
|
|
if tags_changed || saved_change_to_attribute?(:category_id) ||
|
|
saved_change_to_attribute?(:title)
|
|
SearchIndexer.queue_post_reindex(self.id)
|
|
|
|
if tags_changed
|
|
TagUser.auto_watch(topic_id: id)
|
|
TagUser.auto_track(topic_id: id)
|
|
self.tags_changed = false
|
|
end
|
|
end
|
|
|
|
SearchIndexer.index(self)
|
|
end
|
|
|
|
after_update do
|
|
if saved_changes[:category_id] && self.tags.present?
|
|
CategoryTagStat.topic_moved(self, *saved_changes[:category_id])
|
|
elsif saved_changes[:category_id] && self.category&.read_restricted?
|
|
UserProfile.remove_featured_topic_from_all_profiles(self)
|
|
end
|
|
end
|
|
|
|
def initialize_default_values
|
|
self.bumped_at ||= Time.now
|
|
self.last_post_user_id ||= user_id
|
|
end
|
|
|
|
def advance_draft_sequence
|
|
if self.private_message?
|
|
DraftSequence.next!(user, Draft::NEW_PRIVATE_MESSAGE)
|
|
else
|
|
DraftSequence.next!(user, Draft::NEW_TOPIC)
|
|
end
|
|
end
|
|
|
|
def ensure_topic_has_a_category
|
|
if category_id.nil? && (archetype.nil? || self.regular?)
|
|
self.category_id = category&.id || SiteSetting.uncategorized_category_id
|
|
end
|
|
end
|
|
|
|
def self.visible_post_types(viewed_by = nil, include_moderator_actions: true)
|
|
types = Post.types
|
|
result = [types[:regular]]
|
|
result += [types[:moderator_action], types[:small_action]] if include_moderator_actions
|
|
result << types[:whisper] if viewed_by&.whisperer?
|
|
result
|
|
end
|
|
|
|
def self.top_viewed(max = 10)
|
|
Topic.listable_topics.visible.secured.order("views desc").limit(max)
|
|
end
|
|
|
|
def self.recent(max = 10)
|
|
Topic.listable_topics.visible.secured.order("created_at desc").limit(max)
|
|
end
|
|
|
|
def self.count_exceeds_minimum?
|
|
count > SiteSetting.minimum_topics_similar
|
|
end
|
|
|
|
def best_post
|
|
posts
|
|
.where(post_type: Post.types[:regular], user_deleted: false)
|
|
.order("score desc nulls last")
|
|
.limit(1)
|
|
.first
|
|
end
|
|
|
|
def self.has_flag_scope
|
|
ReviewableFlaggedPost.pending_and_default_visible
|
|
end
|
|
|
|
def has_flags?
|
|
self.class.has_flag_scope.exists?(topic_id: self.id)
|
|
end
|
|
|
|
def is_official_warning?
|
|
subtype == TopicSubtype.moderator_warning
|
|
end
|
|
|
|
# all users (in groups or directly targeted) that are going to get the pm
|
|
def all_allowed_users
|
|
moderators_sql = " UNION #{User.moderators.to_sql}" if private_message? &&
|
|
(has_flags? || is_official_warning?)
|
|
User.from(
|
|
"(#{allowed_users.to_sql} UNION #{allowed_group_users.to_sql}#{moderators_sql}) as users",
|
|
)
|
|
end
|
|
|
|
# Additional rate limits on topics: per day and private messages per day
|
|
def limit_topics_per_day
|
|
return unless regular?
|
|
if user && user.new_user_posting_on_first_day?
|
|
limit_first_day_topics_per_day
|
|
else
|
|
apply_per_day_rate_limit_for("topics", :max_topics_per_day)
|
|
end
|
|
end
|
|
|
|
def limit_private_messages_per_day
|
|
return unless private_message?
|
|
apply_per_day_rate_limit_for("pms", :max_personal_messages_per_day)
|
|
end
|
|
|
|
def self.fancy_title(title)
|
|
return unless escaped = ERB::Util.html_escape(title)
|
|
fancy_title = Emoji.unicode_unescape(HtmlPrettify.render(escaped))
|
|
fancy_title.length > Topic.max_fancy_title_length ? escaped : fancy_title
|
|
end
|
|
|
|
def fancy_title
|
|
return ERB::Util.html_escape(title) unless SiteSetting.title_fancy_entities?
|
|
|
|
unless fancy_title = read_attribute(:fancy_title)
|
|
fancy_title = Topic.fancy_title(title)
|
|
write_attribute(:fancy_title, fancy_title)
|
|
|
|
if !new_record? && !Discourse.readonly_mode?
|
|
# make sure data is set in table, this also allows us to change algorithm
|
|
# by simply nulling this column
|
|
DB.exec(
|
|
"UPDATE topics SET fancy_title = :fancy_title where id = :id",
|
|
id: self.id,
|
|
fancy_title: fancy_title,
|
|
)
|
|
end
|
|
end
|
|
|
|
fancy_title
|
|
end
|
|
|
|
# Returns hot topics since a date for display in email digest.
|
|
def self.for_digest(user, since, opts = nil)
|
|
opts ||= {}
|
|
|
|
period = ListController.best_period_for(since)
|
|
|
|
topics =
|
|
Topic
|
|
.visible
|
|
.secured(Guardian.new(user))
|
|
.joins(
|
|
"LEFT OUTER JOIN topic_users ON topic_users.topic_id = topics.id AND topic_users.user_id = #{user.id.to_i}",
|
|
)
|
|
.joins(
|
|
"LEFT OUTER JOIN category_users ON category_users.category_id = topics.category_id AND category_users.user_id = #{user.id.to_i}",
|
|
)
|
|
.joins("LEFT OUTER JOIN users ON users.id = topics.user_id")
|
|
.where(closed: false, archived: false)
|
|
.where(
|
|
"COALESCE(topic_users.notification_level, 1) <> ?",
|
|
TopicUser.notification_levels[:muted],
|
|
)
|
|
.created_since(since)
|
|
.where("topics.created_at < ?", (SiteSetting.editing_grace_period || 0).seconds.ago)
|
|
.listable_topics
|
|
.includes(:category)
|
|
|
|
unless opts[:include_tl0] || user.user_option.try(:include_tl0_in_digests)
|
|
topics = topics.where("COALESCE(users.trust_level, 0) > 0")
|
|
end
|
|
|
|
if !!opts[:top_order]
|
|
topics =
|
|
topics.joins("LEFT OUTER JOIN top_topics ON top_topics.topic_id = topics.id").order(<<~SQL)
|
|
COALESCE(topic_users.notification_level, 1) DESC,
|
|
COALESCE(category_users.notification_level, 1) DESC,
|
|
COALESCE(top_topics.#{TopTopic.score_column_for_period(period)}, 0) DESC,
|
|
topics.bumped_at DESC
|
|
SQL
|
|
end
|
|
|
|
topics = topics.limit(opts[:limit]) if opts[:limit]
|
|
|
|
# Remove category topics
|
|
topics = topics.where.not(id: Category.select(:topic_id).where.not(topic_id: nil))
|
|
|
|
# Remove suppressed categories
|
|
if SiteSetting.digest_suppress_categories.present?
|
|
topics =
|
|
topics.where.not(category_id: SiteSetting.digest_suppress_categories.split("|").map(&:to_i))
|
|
end
|
|
|
|
# Remove suppressed tags
|
|
if SiteSetting.digest_suppress_tags.present?
|
|
tag_ids = Tag.where_name(SiteSetting.digest_suppress_tags.split("|")).pluck(:id)
|
|
|
|
topics =
|
|
topics.where.not(id: TopicTag.where(tag_id: tag_ids).select(:topic_id)) if tag_ids.present?
|
|
end
|
|
|
|
# Remove muted and shared draft categories
|
|
remove_category_ids =
|
|
CategoryUser.where(
|
|
user_id: user.id,
|
|
notification_level: CategoryUser.notification_levels[:muted],
|
|
).pluck(:category_id)
|
|
|
|
remove_category_ids << SiteSetting.shared_drafts_category if SiteSetting.shared_drafts_enabled?
|
|
|
|
if remove_category_ids.present?
|
|
remove_category_ids.uniq!
|
|
topics =
|
|
topics.where(
|
|
"topic_users.notification_level != ? OR topics.category_id NOT IN (?)",
|
|
TopicUser.notification_levels[:muted],
|
|
remove_category_ids,
|
|
)
|
|
end
|
|
|
|
# Remove muted tags
|
|
muted_tag_ids = TagUser.lookup(user, :muted).pluck(:tag_id)
|
|
unless muted_tag_ids.empty?
|
|
# If multiple tags per topic, include topics with tags that aren't muted,
|
|
# and don't forget untagged topics.
|
|
topics =
|
|
topics.where(
|
|
"EXISTS ( SELECT 1 FROM topic_tags WHERE topic_tags.topic_id = topics.id AND tag_id NOT IN (?) )
|
|
OR NOT EXISTS (SELECT 1 FROM topic_tags WHERE topic_tags.topic_id = topics.id)",
|
|
muted_tag_ids,
|
|
)
|
|
end
|
|
|
|
topics
|
|
end
|
|
|
|
def reload(options = nil)
|
|
@post_numbers = nil
|
|
@public_topic_timer = nil
|
|
@slow_mode_topic_timer = nil
|
|
@is_category_topic = nil
|
|
super(options)
|
|
end
|
|
|
|
def post_numbers
|
|
@post_numbers ||= posts.order(:post_number).pluck(:post_number)
|
|
end
|
|
|
|
def age_in_minutes
|
|
((Time.zone.now - created_at) / 1.minute).round
|
|
end
|
|
|
|
def self.listable_count_per_day(
|
|
start_date,
|
|
end_date,
|
|
category_id = nil,
|
|
include_subcategories = false,
|
|
group_ids = nil
|
|
)
|
|
result =
|
|
listable_topics.where(
|
|
"topics.created_at >= ? AND topics.created_at <= ?",
|
|
start_date,
|
|
end_date,
|
|
)
|
|
result = result.group("date(topics.created_at)").order("date(topics.created_at)")
|
|
result =
|
|
result.where(
|
|
category_id: include_subcategories ? Category.subcategory_ids(category_id) : category_id,
|
|
) if category_id
|
|
|
|
if group_ids
|
|
result =
|
|
result
|
|
.joins("INNER JOIN users ON users.id = topics.user_id")
|
|
.joins("INNER JOIN group_users ON group_users.user_id = users.id")
|
|
.where("group_users.group_id IN (?)", group_ids)
|
|
end
|
|
|
|
result.count
|
|
end
|
|
|
|
def private_message?
|
|
self.archetype == Archetype.private_message
|
|
end
|
|
|
|
def regular?
|
|
self.archetype == Archetype.default
|
|
end
|
|
|
|
def open?
|
|
!self.closed?
|
|
end
|
|
|
|
MAX_SIMILAR_BODY_LENGTH = 200
|
|
|
|
def self.similar_to(title, raw, user = nil)
|
|
return [] if SiteSetting.max_similar_results == 0
|
|
return [] if title.blank?
|
|
|
|
raw = raw.presence || ""
|
|
search_data = Search.prepare_data(title.strip)
|
|
|
|
return [] if search_data.blank?
|
|
|
|
tsquery = Search.set_tsquery_weight_filter(search_data, "A")
|
|
|
|
if raw.present?
|
|
cooked =
|
|
SearchIndexer::HtmlScrubber.scrub(PrettyText.cook(raw[0...MAX_SIMILAR_BODY_LENGTH].strip))
|
|
|
|
prepared_data = cooked.present? && Search.prepare_data(cooked)
|
|
|
|
if prepared_data.present?
|
|
raw_tsquery = Search.set_tsquery_weight_filter(prepared_data, "B")
|
|
|
|
tsquery = "#{tsquery} & #{raw_tsquery}"
|
|
end
|
|
end
|
|
|
|
tsquery = Search.to_tsquery(term: tsquery, joiner: "|")
|
|
|
|
guardian = Guardian.new(user)
|
|
|
|
excluded_category_ids_sql =
|
|
Category
|
|
.secured(guardian)
|
|
.where(search_priority: Searchable::PRIORITIES[:ignore])
|
|
.select(:id)
|
|
.to_sql
|
|
|
|
excluded_category_ids_sql = <<~SQL if user
|
|
#{excluded_category_ids_sql}
|
|
UNION
|
|
#{CategoryUser.muted_category_ids_query(user, include_direct: true).select("categories.id").to_sql}
|
|
SQL
|
|
|
|
candidates =
|
|
Topic
|
|
.visible
|
|
.listable_topics
|
|
.secured(guardian)
|
|
.joins("JOIN topic_search_data s ON topics.id = s.topic_id")
|
|
.joins("LEFT JOIN categories c ON topics.id = c.topic_id")
|
|
.where("search_data @@ #{tsquery}")
|
|
.where("c.topic_id IS NULL")
|
|
.where("topics.category_id NOT IN (#{excluded_category_ids_sql})")
|
|
.order("ts_rank(search_data, #{tsquery}) DESC")
|
|
.limit(SiteSetting.max_similar_results * 3)
|
|
|
|
candidate_ids = candidates.pluck(:id)
|
|
|
|
return [] if candidate_ids.blank?
|
|
|
|
similars =
|
|
Topic
|
|
.joins("JOIN posts AS p ON p.topic_id = topics.id AND p.post_number = 1")
|
|
.where("topics.id IN (?)", candidate_ids)
|
|
.order("similarity DESC")
|
|
.limit(SiteSetting.max_similar_results)
|
|
|
|
if raw.present?
|
|
similars.select(
|
|
DB.sql_fragment(
|
|
"topics.*, similarity(topics.title, :title) + similarity(p.raw, :raw) AS similarity, p.cooked AS blurb",
|
|
title: title,
|
|
raw: raw,
|
|
),
|
|
).where(
|
|
"similarity(topics.title, :title) + similarity(p.raw, :raw) > 0.2",
|
|
title: title,
|
|
raw: raw,
|
|
)
|
|
else
|
|
similars.select(
|
|
DB.sql_fragment(
|
|
"topics.*, similarity(topics.title, :title) AS similarity, p.cooked AS blurb",
|
|
title: title,
|
|
),
|
|
).where("similarity(topics.title, :title) > 0.2", title: title)
|
|
end
|
|
end
|
|
|
|
def update_status(status, enabled, user, opts = {})
|
|
TopicStatusUpdater.new(self, user).update!(status, enabled, opts)
|
|
DiscourseEvent.trigger(:topic_status_updated, self, status, enabled)
|
|
|
|
if status == "closed"
|
|
StaffActionLogger.new(user).log_topic_closed(self, closed: enabled)
|
|
elsif status == "archived"
|
|
StaffActionLogger.new(user).log_topic_archived(self, archived: enabled)
|
|
end
|
|
|
|
if enabled && private_message? && status.to_s["closed"]
|
|
group_ids = user.groups.pluck(:id)
|
|
if group_ids.present?
|
|
allowed_group_ids =
|
|
self.allowed_groups.where("topic_allowed_groups.group_id IN (?)", group_ids).pluck(:id)
|
|
allowed_group_ids.each { |id| GroupArchivedMessage.archive!(id, self) }
|
|
end
|
|
end
|
|
end
|
|
|
|
# Atomically creates the next post number
|
|
def self.next_post_number(topic_id, opts = {})
|
|
highest =
|
|
DB
|
|
.query_single(
|
|
"SELECT coalesce(max(post_number),0) AS max FROM posts WHERE topic_id = ?",
|
|
topic_id,
|
|
)
|
|
.first
|
|
.to_i
|
|
|
|
if opts[:whisper]
|
|
result = DB.query_single(<<~SQL, highest, topic_id)
|
|
UPDATE topics
|
|
SET highest_staff_post_number = ? + 1
|
|
WHERE id = ?
|
|
RETURNING highest_staff_post_number
|
|
SQL
|
|
|
|
result.first.to_i
|
|
else
|
|
reply_sql = opts[:reply] ? ", reply_count = reply_count + 1" : ""
|
|
posts_sql = opts[:post] ? ", posts_count = posts_count + 1" : ""
|
|
|
|
result = DB.query_single(<<~SQL, highest: highest, topic_id: topic_id)
|
|
UPDATE topics
|
|
SET highest_staff_post_number = :highest + 1,
|
|
highest_post_number = :highest + 1
|
|
#{reply_sql}
|
|
#{posts_sql}
|
|
WHERE id = :topic_id
|
|
RETURNING highest_post_number
|
|
SQL
|
|
|
|
result.first.to_i
|
|
end
|
|
end
|
|
|
|
def self.reset_all_highest!
|
|
DB.exec <<~SQL
|
|
WITH
|
|
X as (
|
|
SELECT topic_id,
|
|
COALESCE(MAX(post_number), 0) highest_post_number
|
|
FROM posts
|
|
WHERE deleted_at IS NULL
|
|
GROUP BY topic_id
|
|
),
|
|
Y as (
|
|
SELECT topic_id,
|
|
coalesce(MAX(post_number), 0) highest_post_number,
|
|
count(*) posts_count,
|
|
max(created_at) last_posted_at
|
|
FROM posts
|
|
WHERE deleted_at IS NULL AND post_type <> 4
|
|
GROUP BY topic_id
|
|
),
|
|
Z as (
|
|
SELECT topic_id,
|
|
SUM(COALESCE(posts.word_count, 0)) word_count
|
|
FROM posts
|
|
WHERE deleted_at IS NULL AND post_type <> 4
|
|
GROUP BY topic_id
|
|
)
|
|
UPDATE topics
|
|
SET
|
|
highest_staff_post_number = X.highest_post_number,
|
|
highest_post_number = Y.highest_post_number,
|
|
last_posted_at = Y.last_posted_at,
|
|
posts_count = Y.posts_count,
|
|
word_count = Z.word_count
|
|
FROM X, Y, Z
|
|
WHERE
|
|
topics.archetype <> 'private_message' AND
|
|
X.topic_id = topics.id AND
|
|
Y.topic_id = topics.id AND
|
|
Z.topic_id = topics.id AND (
|
|
topics.highest_staff_post_number <> X.highest_post_number OR
|
|
topics.highest_post_number <> Y.highest_post_number OR
|
|
topics.last_posted_at <> Y.last_posted_at OR
|
|
topics.posts_count <> Y.posts_count OR
|
|
topics.word_count <> Z.word_count
|
|
)
|
|
SQL
|
|
|
|
DB.exec <<~SQL
|
|
WITH
|
|
X as (
|
|
SELECT topic_id,
|
|
COALESCE(MAX(post_number), 0) highest_post_number
|
|
FROM posts
|
|
WHERE deleted_at IS NULL
|
|
GROUP BY topic_id
|
|
),
|
|
Y as (
|
|
SELECT topic_id,
|
|
coalesce(MAX(post_number), 0) highest_post_number,
|
|
count(*) posts_count,
|
|
max(created_at) last_posted_at
|
|
FROM posts
|
|
WHERE deleted_at IS NULL AND post_type <> 3 AND post_type <> 4
|
|
GROUP BY topic_id
|
|
),
|
|
Z as (
|
|
SELECT topic_id,
|
|
SUM(COALESCE(posts.word_count, 0)) word_count
|
|
FROM posts
|
|
WHERE deleted_at IS NULL AND post_type <> 3 AND post_type <> 4
|
|
GROUP BY topic_id
|
|
)
|
|
UPDATE topics
|
|
SET
|
|
highest_staff_post_number = X.highest_post_number,
|
|
highest_post_number = Y.highest_post_number,
|
|
last_posted_at = Y.last_posted_at,
|
|
posts_count = Y.posts_count,
|
|
word_count = Z.word_count
|
|
FROM X, Y, Z
|
|
WHERE
|
|
topics.archetype = 'private_message' AND
|
|
X.topic_id = topics.id AND
|
|
Y.topic_id = topics.id AND
|
|
Z.topic_id = topics.id AND (
|
|
topics.highest_staff_post_number <> X.highest_post_number OR
|
|
topics.highest_post_number <> Y.highest_post_number OR
|
|
topics.last_posted_at <> Y.last_posted_at OR
|
|
topics.posts_count <> Y.posts_count OR
|
|
topics.word_count <> Z.word_count
|
|
)
|
|
SQL
|
|
end
|
|
|
|
# If a post is deleted we have to update our highest post counters and last post information
|
|
def self.reset_highest(topic_id)
|
|
archetype = Topic.where(id: topic_id).pick(:archetype)
|
|
|
|
# ignore small_action replies for private messages
|
|
post_type =
|
|
archetype == Archetype.private_message ? " AND post_type <> #{Post.types[:small_action]}" : ""
|
|
|
|
result = DB.query_single(<<~SQL, topic_id: topic_id)
|
|
UPDATE topics
|
|
SET
|
|
highest_staff_post_number = (
|
|
SELECT COALESCE(MAX(post_number), 0) FROM posts
|
|
WHERE topic_id = :topic_id AND
|
|
deleted_at IS NULL
|
|
),
|
|
highest_post_number = (
|
|
SELECT COALESCE(MAX(post_number), 0) FROM posts
|
|
WHERE topic_id = :topic_id AND
|
|
deleted_at IS NULL AND
|
|
post_type <> 4
|
|
#{post_type}
|
|
),
|
|
posts_count = (
|
|
SELECT count(*) FROM posts
|
|
WHERE deleted_at IS NULL AND
|
|
topic_id = :topic_id AND
|
|
post_type <> 4
|
|
#{post_type}
|
|
),
|
|
word_count = (
|
|
SELECT SUM(COALESCE(posts.word_count, 0)) FROM posts
|
|
WHERE topic_id = :topic_id AND
|
|
deleted_at IS NULL AND
|
|
post_type <> 4
|
|
#{post_type}
|
|
),
|
|
last_posted_at = (
|
|
SELECT MAX(created_at) FROM posts
|
|
WHERE topic_id = :topic_id AND
|
|
deleted_at IS NULL AND
|
|
post_type <> 4
|
|
#{post_type}
|
|
),
|
|
last_post_user_id = COALESCE((
|
|
SELECT user_id FROM posts
|
|
WHERE topic_id = :topic_id AND
|
|
deleted_at IS NULL AND
|
|
post_type <> 4
|
|
#{post_type}
|
|
ORDER BY created_at desc
|
|
LIMIT 1
|
|
), last_post_user_id)
|
|
WHERE id = :topic_id
|
|
RETURNING highest_post_number
|
|
SQL
|
|
|
|
highest_post_number = result.first.to_i
|
|
|
|
# Update the forum topic user records
|
|
DB.exec(<<~SQL, highest: highest_post_number, topic_id: topic_id)
|
|
UPDATE topic_users
|
|
SET last_read_post_number = CASE
|
|
WHEN last_read_post_number > :highest THEN :highest
|
|
ELSE last_read_post_number
|
|
END
|
|
WHERE topic_id = :topic_id
|
|
SQL
|
|
end
|
|
|
|
cattr_accessor :update_featured_topics
|
|
|
|
def changed_to_category(new_category)
|
|
return true if new_category.blank? || Category.exists?(topic_id: id)
|
|
|
|
if new_category.id == SiteSetting.uncategorized_category_id &&
|
|
!SiteSetting.allow_uncategorized_topics
|
|
return false
|
|
end
|
|
|
|
Topic.transaction do
|
|
old_category = category
|
|
|
|
if self.category_id != new_category.id
|
|
self.update(category_id: new_category.id)
|
|
|
|
if old_category
|
|
Category.where(id: old_category.id).update_all("topic_count = topic_count - 1")
|
|
|
|
count =
|
|
if old_category.read_restricted && !new_category.read_restricted
|
|
1
|
|
elsif !old_category.read_restricted && new_category.read_restricted
|
|
-1
|
|
end
|
|
|
|
Tag.update_counters(self.tags, { public_topic_count: count }) if count
|
|
end
|
|
|
|
# when a topic changes category we may have to start watching it
|
|
# if we happen to have read state for it
|
|
CategoryUser.auto_watch(category_id: new_category.id, topic_id: self.id)
|
|
CategoryUser.auto_track(category_id: new_category.id, topic_id: self.id)
|
|
|
|
if !SiteSetting.disable_category_edit_notifications && (post = self.ordered_posts.first)
|
|
notified_user_ids = [post.user_id, post.last_editor_id].uniq
|
|
DB.after_commit do
|
|
Jobs.enqueue(
|
|
:notify_category_change,
|
|
post_id: post.id,
|
|
notified_user_ids: notified_user_ids,
|
|
)
|
|
end
|
|
end
|
|
|
|
# when a topic changes category we may need to make uploads
|
|
# linked to posts secure/not secure depending on whether the
|
|
# category is private. this is only done if the category
|
|
# has actually changed to avoid noise.
|
|
DB.after_commit { Jobs.enqueue(:update_topic_upload_security, topic_id: self.id) }
|
|
end
|
|
|
|
Category.where(id: new_category.id).update_all("topic_count = topic_count + 1")
|
|
|
|
if Topic.update_featured_topics != false
|
|
CategoryFeaturedTopic.feature_topics_for(old_category) unless @import_mode
|
|
unless @import_mode || old_category.try(:id) == new_category.id
|
|
CategoryFeaturedTopic.feature_topics_for(new_category)
|
|
end
|
|
end
|
|
end
|
|
|
|
true
|
|
end
|
|
|
|
def add_small_action(user, action_code, who = nil, opts = {})
|
|
custom_fields = {}
|
|
custom_fields["action_code_who"] = who if who.present?
|
|
opts =
|
|
opts.merge(
|
|
post_type: Post.types[:small_action],
|
|
action_code: action_code,
|
|
custom_fields: custom_fields,
|
|
)
|
|
|
|
add_moderator_post(user, nil, opts)
|
|
end
|
|
|
|
def add_moderator_post(user, text, opts = nil)
|
|
opts ||= {}
|
|
new_post = nil
|
|
creator =
|
|
PostCreator.new(
|
|
user,
|
|
raw: text,
|
|
post_type: opts[:post_type] || Post.types[:moderator_action],
|
|
action_code: opts[:action_code],
|
|
no_bump: opts[:bump].blank?,
|
|
topic_id: self.id,
|
|
silent: opts[:silent],
|
|
skip_validations: true,
|
|
custom_fields: opts[:custom_fields],
|
|
import_mode: opts[:import_mode],
|
|
)
|
|
|
|
if (new_post = creator.create) && new_post.present?
|
|
increment!(:moderator_posts_count) if new_post.persisted?
|
|
# If we are moving posts, we want to insert the moderator post where the previous posts were
|
|
# in the stream, not at the end.
|
|
if opts[:post_number].present?
|
|
new_post.update!(post_number: opts[:post_number], sort_order: opts[:post_number])
|
|
end
|
|
|
|
# Grab any links that are present
|
|
TopicLink.extract_from(new_post)
|
|
QuotedPost.extract_from(new_post)
|
|
end
|
|
|
|
new_post
|
|
end
|
|
|
|
def change_category_to_id(category_id)
|
|
return false if private_message?
|
|
|
|
new_category_id = category_id.to_i
|
|
# if the category name is blank, reset the attribute
|
|
new_category_id = SiteSetting.uncategorized_category_id if new_category_id == 0
|
|
|
|
return true if self.category_id == new_category_id
|
|
|
|
cat = Category.find_by(id: new_category_id)
|
|
return false unless cat
|
|
|
|
reviewables.update_all(category_id: new_category_id)
|
|
|
|
changed_to_category(cat)
|
|
end
|
|
|
|
def remove_allowed_group(removed_by, name)
|
|
if group = Group.find_by(name: name)
|
|
group_user = topic_allowed_groups.find_by(group_id: group.id)
|
|
if group_user
|
|
group_user.destroy
|
|
allowed_groups.reload
|
|
add_small_action(removed_by, "removed_group", group.name)
|
|
return true
|
|
end
|
|
end
|
|
|
|
false
|
|
end
|
|
|
|
def remove_allowed_user(removed_by, username)
|
|
user = username.is_a?(User) ? username : User.find_by(username: username)
|
|
|
|
if user
|
|
topic_user = topic_allowed_users.find_by(user_id: user.id)
|
|
|
|
if topic_user
|
|
if user.id == removed_by&.id
|
|
add_small_action(removed_by, "user_left", user.username)
|
|
else
|
|
add_small_action(removed_by, "removed_user", user.username)
|
|
end
|
|
|
|
topic_user.destroy
|
|
return true
|
|
end
|
|
end
|
|
|
|
false
|
|
end
|
|
|
|
def reached_recipients_limit?
|
|
return false unless private_message?
|
|
topic_allowed_users.count + topic_allowed_groups.count >=
|
|
SiteSetting.max_allowed_message_recipients
|
|
end
|
|
|
|
def invite_group(user, group)
|
|
TopicAllowedGroup.create!(topic_id: self.id, group_id: group.id)
|
|
self.allowed_groups.reload
|
|
|
|
last_post =
|
|
self.posts.order("post_number desc").where("not hidden AND posts.deleted_at IS NULL").first
|
|
if last_post
|
|
Jobs.enqueue(:post_alert, post_id: last_post.id)
|
|
add_small_action(user, "invited_group", group.name)
|
|
Jobs.enqueue(:group_pm_alert, user_id: user.id, group_id: group.id, post_id: last_post.id)
|
|
end
|
|
|
|
# If the group invited includes the OP of the topic as one of is members,
|
|
# we cannot strip the topic_allowed_user record since it will be more
|
|
# complicated to recover the topic_allowed_user record for the OP if the
|
|
# group is removed.
|
|
allowed_user_where_clause = <<~SQL
|
|
users.id IN (
|
|
SELECT topic_allowed_users.user_id
|
|
FROM topic_allowed_users
|
|
INNER JOIN group_users ON group_users.user_id = topic_allowed_users.user_id
|
|
INNER JOIN topic_allowed_groups ON topic_allowed_groups.group_id = group_users.group_id
|
|
WHERE topic_allowed_groups.group_id = :group_id AND
|
|
topic_allowed_users.topic_id = :topic_id AND
|
|
topic_allowed_users.user_id != :op_user_id
|
|
)
|
|
SQL
|
|
User
|
|
.where(
|
|
[
|
|
allowed_user_where_clause,
|
|
{ group_id: group.id, topic_id: self.id, op_user_id: self.user_id },
|
|
],
|
|
)
|
|
.find_each { |allowed_user| remove_allowed_user(Discourse.system_user, allowed_user) }
|
|
|
|
true
|
|
end
|
|
|
|
def invite(invited_by, username_or_email, group_ids = nil, custom_message = nil)
|
|
guardian = Guardian.new(invited_by)
|
|
|
|
if target_user = User.find_by_username_or_email(username_or_email)
|
|
if topic_allowed_users.exists?(user_id: target_user.id)
|
|
raise UserExists.new(I18n.t("topic_invite.user_exists"))
|
|
end
|
|
|
|
comm_screener = UserCommScreener.new(acting_user: invited_by, target_user_ids: target_user.id)
|
|
if comm_screener.ignoring_or_muting_actor?(target_user.id)
|
|
raise NotAllowed.new(I18n.t("not_accepting_pms", username: target_user.username))
|
|
end
|
|
|
|
if TopicUser.where(
|
|
topic: self,
|
|
user: target_user,
|
|
notification_level: TopicUser.notification_levels[:muted],
|
|
).exists?
|
|
raise NotAllowed.new(I18n.t("topic_invite.muted_topic"))
|
|
end
|
|
|
|
if comm_screener.disallowing_pms_from_actor?(target_user.id)
|
|
raise NotAllowed.new(I18n.t("topic_invite.receiver_does_not_allow_pm"))
|
|
end
|
|
|
|
if UserCommScreener.new(
|
|
acting_user: target_user,
|
|
target_user_ids: invited_by.id,
|
|
).disallowing_pms_from_actor?(invited_by.id)
|
|
raise NotAllowed.new(I18n.t("topic_invite.sender_does_not_allow_pm"))
|
|
end
|
|
|
|
if private_message?
|
|
!!invite_to_private_message(invited_by, target_user, guardian)
|
|
else
|
|
!!invite_to_topic(invited_by, target_user, group_ids, guardian)
|
|
end
|
|
elsif username_or_email =~ /\A.+@.+\z/ && guardian.can_invite_via_email?(self)
|
|
!!Invite.generate(
|
|
invited_by,
|
|
email: username_or_email,
|
|
topic: self,
|
|
group_ids: group_ids,
|
|
custom_message: custom_message,
|
|
invite_to_topic: true,
|
|
)
|
|
end
|
|
end
|
|
|
|
def email_already_exists_for?(invite)
|
|
invite.email_already_exists && private_message?
|
|
end
|
|
|
|
def grant_permission_to_user(lower_email)
|
|
user = User.find_by_email(lower_email)
|
|
unless topic_allowed_users.exists?(user_id: user.id)
|
|
topic_allowed_users.create!(user_id: user.id)
|
|
end
|
|
end
|
|
|
|
def max_post_number
|
|
posts.with_deleted.maximum(:post_number).to_i
|
|
end
|
|
|
|
def move_posts(moved_by, post_ids, opts)
|
|
post_mover =
|
|
PostMover.new(
|
|
self,
|
|
moved_by,
|
|
post_ids,
|
|
move_to_pm: opts[:archetype].present? && opts[:archetype] == "private_message",
|
|
)
|
|
|
|
if opts[:destination_topic_id]
|
|
topic =
|
|
post_mover.to_topic(
|
|
opts[:destination_topic_id],
|
|
**opts.slice(:participants, :chronological_order),
|
|
)
|
|
|
|
DiscourseEvent.trigger(:topic_merged, post_mover.original_topic, post_mover.destination_topic)
|
|
|
|
topic
|
|
elsif opts[:title]
|
|
post_mover.to_new_topic(opts[:title], opts[:category_id], opts[:tags])
|
|
end
|
|
end
|
|
|
|
# Updates the denormalized statistics of a topic including featured posters. They shouldn't
|
|
# go out of sync unless you do something drastic live move posts from one topic to another.
|
|
# this recalculates everything.
|
|
def update_statistics
|
|
feature_topic_users
|
|
update_action_counts
|
|
Topic.reset_highest(id)
|
|
end
|
|
|
|
def update_action_counts
|
|
update_column(
|
|
:like_count,
|
|
Post.where.not(post_type: Post.types[:whisper]).where(topic_id: id).sum(:like_count),
|
|
)
|
|
end
|
|
|
|
def posters_summary(options = {}) # avatar lookup in options
|
|
@posters_summary ||= TopicPostersSummary.new(self, options).summary
|
|
end
|
|
|
|
def participants_summary(options = {})
|
|
@participants_summary ||= TopicParticipantsSummary.new(self, options).summary
|
|
end
|
|
|
|
def participant_groups_summary(options = {})
|
|
@participant_groups_summary ||= TopicParticipantGroupsSummary.new(self, options).summary
|
|
end
|
|
|
|
def make_banner!(user, bannered_until = nil)
|
|
if bannered_until
|
|
bannered_until =
|
|
begin
|
|
Time.parse(bannered_until)
|
|
rescue ArgumentError
|
|
raise Discourse::InvalidParameters.new(:bannered_until)
|
|
end
|
|
end
|
|
|
|
# only one banner at the same time
|
|
previous_banner = Topic.where(archetype: Archetype.banner).first
|
|
previous_banner.remove_banner!(user) if previous_banner.present?
|
|
|
|
UserProfile.where("dismissed_banner_key IS NOT NULL").update_all(dismissed_banner_key: nil)
|
|
|
|
self.archetype = Archetype.banner
|
|
self.bannered_until = bannered_until
|
|
self.add_small_action(user, "banner.enabled")
|
|
self.save
|
|
|
|
MessageBus.publish("/site/banner", banner)
|
|
|
|
Jobs.cancel_scheduled_job(:remove_banner, topic_id: self.id)
|
|
Jobs.enqueue_at(bannered_until, :remove_banner, topic_id: self.id) if bannered_until
|
|
end
|
|
|
|
def remove_banner!(user)
|
|
self.archetype = Archetype.default
|
|
self.bannered_until = nil
|
|
self.add_small_action(user, "banner.disabled")
|
|
self.save
|
|
|
|
MessageBus.publish("/site/banner", nil)
|
|
|
|
Jobs.cancel_scheduled_job(:remove_banner, topic_id: self.id)
|
|
end
|
|
|
|
def banner
|
|
post = self.ordered_posts.first
|
|
|
|
{ html: post.cooked, key: self.id, url: self.url }
|
|
end
|
|
|
|
cattr_accessor :slug_computed_callbacks
|
|
self.slug_computed_callbacks = []
|
|
|
|
def slug_for_topic(title)
|
|
return "" if title.blank?
|
|
slug = Slug.for(title)
|
|
|
|
# this is a hook for plugins that need to modify the generated slug
|
|
self.class.slug_computed_callbacks.each { |callback| slug = callback.call(self, slug, title) }
|
|
|
|
slug
|
|
end
|
|
|
|
# Even if the slug column in the database is null, topic.slug will return something:
|
|
def slug
|
|
unless slug = read_attribute(:slug)
|
|
return "" if title.blank?
|
|
slug = slug_for_topic(title)
|
|
if new_record?
|
|
write_attribute(:slug, slug)
|
|
else
|
|
update_column(:slug, slug)
|
|
end
|
|
end
|
|
|
|
slug
|
|
end
|
|
|
|
def self.find_by_slug(slug)
|
|
if SiteSetting.slug_generation_method != "encoded"
|
|
Topic.find_by(slug: slug.downcase)
|
|
else
|
|
encoded_slug = CGI.escape(slug)
|
|
Topic.find_by(slug: encoded_slug)
|
|
end
|
|
end
|
|
|
|
def title=(t)
|
|
slug = slug_for_topic(t.to_s)
|
|
write_attribute(:slug, slug)
|
|
write_attribute(:fancy_title, nil)
|
|
write_attribute(:title, t)
|
|
end
|
|
|
|
# NOTE: These are probably better off somewhere else.
|
|
# Having a model know about URLs seems a bit strange.
|
|
def last_post_url
|
|
"#{Discourse.base_path}/t/#{slug}/#{id}/#{posts_count}"
|
|
end
|
|
|
|
def self.url(id, slug, post_number = nil)
|
|
url = +"#{Discourse.base_url}/t/#{slug}/#{id}"
|
|
url << "/#{post_number}" if post_number.to_i > 1
|
|
url
|
|
end
|
|
|
|
def url(post_number = nil)
|
|
self.class.url id, slug, post_number
|
|
end
|
|
|
|
def self.relative_url(id, slug, post_number = nil)
|
|
url = +"#{Discourse.base_path}/t/"
|
|
url << "#{slug}/" if slug.present?
|
|
url << id.to_s
|
|
url << "/#{post_number}" if post_number.to_i > 1
|
|
url
|
|
end
|
|
|
|
def slugless_url(post_number = nil)
|
|
Topic.relative_url(id, nil, post_number)
|
|
end
|
|
|
|
def relative_url(post_number = nil)
|
|
Topic.relative_url(id, slug, post_number)
|
|
end
|
|
|
|
def clear_pin_for(user)
|
|
return if user.blank?
|
|
TopicUser.change(user.id, id, cleared_pinned_at: Time.now)
|
|
end
|
|
|
|
def re_pin_for(user)
|
|
return if user.blank?
|
|
TopicUser.change(user.id, id, cleared_pinned_at: nil)
|
|
end
|
|
|
|
def update_pinned(status, global = false, pinned_until = nil)
|
|
if pinned_until
|
|
pinned_until =
|
|
begin
|
|
Time.parse(pinned_until)
|
|
rescue ArgumentError
|
|
raise Discourse::InvalidParameters.new(:pinned_until)
|
|
end
|
|
end
|
|
|
|
update_columns(
|
|
pinned_at: status ? Time.zone.now : nil,
|
|
pinned_globally: global,
|
|
pinned_until: pinned_until,
|
|
)
|
|
|
|
Jobs.cancel_scheduled_job(:unpin_topic, topic_id: self.id)
|
|
Jobs.enqueue_at(pinned_until, :unpin_topic, topic_id: self.id) if pinned_until
|
|
end
|
|
|
|
def draft_key
|
|
"#{Draft::EXISTING_TOPIC}#{id}"
|
|
end
|
|
|
|
def notifier
|
|
@topic_notifier ||= TopicNotifier.new(self)
|
|
end
|
|
|
|
def muted?(user)
|
|
notifier.muted?(user.id) if user && user.id
|
|
end
|
|
|
|
def self.ensure_consistency!
|
|
# unpin topics that might have been missed
|
|
Topic.where("pinned_until < ?", Time.now).update_all(
|
|
pinned_at: nil,
|
|
pinned_globally: false,
|
|
pinned_until: nil,
|
|
)
|
|
Topic
|
|
.where("bannered_until < ?", Time.now)
|
|
.find_each { |topic| topic.remove_banner!(Discourse.system_user) }
|
|
end
|
|
|
|
def inherit_slow_mode_from_category
|
|
if self.category&.default_slow_mode_seconds
|
|
self.slow_mode_seconds = self.category&.default_slow_mode_seconds
|
|
end
|
|
end
|
|
|
|
def inherit_auto_close_from_category(timer_type: :close)
|
|
auto_close_hours = self.category&.auto_close_hours
|
|
|
|
if self.open? && !@ignore_category_auto_close && auto_close_hours.present? &&
|
|
public_topic_timer&.execute_at.blank?
|
|
based_on_last_post = self.category.auto_close_based_on_last_post
|
|
duration_minutes = based_on_last_post ? auto_close_hours * 60 : nil
|
|
|
|
# the timer time can be a timestamp or an integer based
|
|
# on the number of hours
|
|
auto_close_time = auto_close_hours
|
|
|
|
if !based_on_last_post
|
|
# set auto close to the original time it should have been
|
|
# when the topic was first created.
|
|
start_time = self.created_at || Time.zone.now
|
|
auto_close_time = start_time + auto_close_hours.hours
|
|
|
|
# if we have already passed the original close time then
|
|
# we should not recreate the auto-close timer for the topic
|
|
return if auto_close_time < Time.zone.now
|
|
|
|
# timestamp must be a string for set_or_create_timer
|
|
auto_close_time = auto_close_time.to_s
|
|
end
|
|
|
|
self.set_or_create_timer(
|
|
TopicTimer.types[timer_type],
|
|
auto_close_time,
|
|
by_user: Discourse.system_user,
|
|
based_on_last_post: based_on_last_post,
|
|
duration_minutes: duration_minutes,
|
|
)
|
|
end
|
|
end
|
|
|
|
def public_topic_timer
|
|
@public_topic_timer ||= topic_timers.find_by(public_type: true)
|
|
end
|
|
|
|
def slow_mode_topic_timer
|
|
@slow_mode_topic_timer ||= topic_timers.find_by(status_type: TopicTimer.types[:clear_slow_mode])
|
|
end
|
|
|
|
def delete_topic_timer(status_type, by_user: Discourse.system_user)
|
|
options = { status_type: status_type }
|
|
options.merge!(user: by_user) unless TopicTimer.public_types[status_type]
|
|
self.topic_timers.find_by(options)&.trash!(by_user)
|
|
@public_topic_timer = nil
|
|
nil
|
|
end
|
|
|
|
# Valid arguments for the time:
|
|
# * An integer, which is the number of hours from now to update the topic's status.
|
|
# * A timestamp, like "2013-11-25 13:00", when the topic's status should update.
|
|
# * A timestamp with timezone in JSON format. (e.g., "2013-11-26T21:00:00.000Z")
|
|
# * `nil` to delete the topic's status update.
|
|
# Options:
|
|
# * by_user: User who is setting the topic's status update.
|
|
# * based_on_last_post: True if time should be based on timestamp of the last post.
|
|
# * category_id: Category that the update will apply to.
|
|
# * duration_minutes: The duration of the timer in minutes, which is used if the timer is based
|
|
# on the last post or if the timer type is delete_replies.
|
|
# * silent: Affects whether the close topic timer status change will be silent or not.
|
|
def set_or_create_timer(
|
|
status_type,
|
|
time,
|
|
by_user: nil,
|
|
based_on_last_post: false,
|
|
category_id: SiteSetting.uncategorized_category_id,
|
|
duration_minutes: nil,
|
|
silent: nil
|
|
)
|
|
if time.blank? && duration_minutes.blank?
|
|
return delete_topic_timer(status_type, by_user: by_user)
|
|
end
|
|
|
|
duration_minutes = duration_minutes ? duration_minutes.to_i : 0
|
|
public_topic_timer = !!TopicTimer.public_types[status_type]
|
|
topic_timer_options = { topic: self, public_type: public_topic_timer }
|
|
topic_timer_options.merge!(user: by_user) unless public_topic_timer
|
|
topic_timer_options.merge!(silent: silent) if silent
|
|
topic_timer = TopicTimer.find_or_initialize_by(topic_timer_options)
|
|
topic_timer.status_type = status_type
|
|
|
|
time_now = Time.zone.now
|
|
topic_timer.based_on_last_post = !based_on_last_post.blank?
|
|
|
|
if status_type == TopicTimer.types[:publish_to_category]
|
|
topic_timer.category = Category.find_by(id: category_id)
|
|
end
|
|
|
|
if topic_timer.based_on_last_post
|
|
if duration_minutes > 0
|
|
last_post_created_at =
|
|
self.ordered_posts.last.present? ? self.ordered_posts.last.created_at : time_now
|
|
topic_timer.duration_minutes = duration_minutes
|
|
topic_timer.execute_at = last_post_created_at + duration_minutes.minutes
|
|
topic_timer.created_at = last_post_created_at
|
|
end
|
|
elsif topic_timer.status_type == TopicTimer.types[:delete_replies]
|
|
if duration_minutes > 0
|
|
first_reply_created_at =
|
|
(self.ordered_posts.where("post_number > 1").minimum(:created_at) || time_now)
|
|
topic_timer.duration_minutes = duration_minutes
|
|
topic_timer.execute_at = first_reply_created_at + duration_minutes.minutes
|
|
topic_timer.created_at = first_reply_created_at
|
|
end
|
|
else
|
|
utc = Time.find_zone("UTC")
|
|
is_float =
|
|
(
|
|
begin
|
|
Float(time)
|
|
rescue StandardError
|
|
nil
|
|
end
|
|
)
|
|
|
|
if is_float
|
|
num_hours = time.to_f
|
|
topic_timer.execute_at = num_hours.hours.from_now if num_hours > 0
|
|
else
|
|
timestamp = utc.parse(time)
|
|
raise Discourse::InvalidParameters unless timestamp && timestamp > utc.now
|
|
# a timestamp in client's time zone, like "2015-5-27 12:00"
|
|
topic_timer.execute_at = timestamp
|
|
end
|
|
end
|
|
|
|
if topic_timer.execute_at
|
|
if by_user&.staff? || by_user&.trust_level == TrustLevel[4]
|
|
topic_timer.user = by_user
|
|
else
|
|
topic_timer.user ||=
|
|
(
|
|
if self.user.staff? || self.user.trust_level == TrustLevel[4]
|
|
self.user
|
|
else
|
|
Discourse.system_user
|
|
end
|
|
)
|
|
end
|
|
|
|
if self.persisted?
|
|
# See TopicTimer.after_save for additional context; the topic
|
|
# status may be changed by saving.
|
|
topic_timer.save!
|
|
else
|
|
self.topic_timers << topic_timer
|
|
end
|
|
|
|
topic_timer
|
|
end
|
|
end
|
|
|
|
def read_restricted_category?
|
|
category && category.read_restricted
|
|
end
|
|
|
|
def category_allows_unlimited_owner_edits_on_first_post?
|
|
category && category.allow_unlimited_owner_edits_on_first_post?
|
|
end
|
|
|
|
def acting_user
|
|
@acting_user || user
|
|
end
|
|
|
|
def acting_user=(u)
|
|
@acting_user = u
|
|
end
|
|
|
|
def secure_group_ids
|
|
@secure_group_ids ||=
|
|
(self.category.secure_group_ids if self.category && self.category.read_restricted?)
|
|
end
|
|
|
|
def has_topic_embed?
|
|
TopicEmbed.where(topic_id: id).exists?
|
|
end
|
|
|
|
def expandable_first_post?
|
|
SiteSetting.embed_truncate? && has_topic_embed?
|
|
end
|
|
|
|
def message_archived?(user)
|
|
return false unless user && user.id
|
|
|
|
# tricky query but this checks to see if message is archived for ALL groups you belong to
|
|
# OR if you have it archived as a user explicitly
|
|
|
|
sql = <<~SQL
|
|
SELECT 1
|
|
WHERE
|
|
(
|
|
SELECT count(*) FROM topic_allowed_groups tg
|
|
JOIN group_archived_messages gm
|
|
ON gm.topic_id = tg.topic_id AND
|
|
gm.group_id = tg.group_id
|
|
WHERE tg.group_id IN (SELECT g.group_id FROM group_users g WHERE g.user_id = :user_id)
|
|
AND tg.topic_id = :topic_id
|
|
) =
|
|
(
|
|
SELECT case when count(*) = 0 then -1 else count(*) end FROM topic_allowed_groups tg
|
|
WHERE tg.group_id IN (SELECT g.group_id FROM group_users g WHERE g.user_id = :user_id)
|
|
AND tg.topic_id = :topic_id
|
|
)
|
|
|
|
UNION ALL
|
|
|
|
SELECT 1 FROM topic_allowed_users tu
|
|
JOIN user_archived_messages um ON um.user_id = tu.user_id AND um.topic_id = tu.topic_id
|
|
WHERE tu.user_id = :user_id AND tu.topic_id = :topic_id
|
|
SQL
|
|
|
|
DB.exec(sql, user_id: user.id, topic_id: id) > 0
|
|
end
|
|
|
|
TIME_TO_FIRST_RESPONSE_SQL = <<-SQL
|
|
SELECT AVG(t.hours)::float AS "hours", t.created_at AS "date"
|
|
FROM (
|
|
SELECT t.id, t.created_at::date AS created_at, EXTRACT(EPOCH FROM MIN(p.created_at) - t.created_at)::float / 3600.0 AS "hours"
|
|
FROM topics t
|
|
LEFT JOIN posts p ON p.topic_id = t.id
|
|
/*where*/
|
|
GROUP BY t.id
|
|
) t
|
|
GROUP BY t.created_at
|
|
ORDER BY t.created_at
|
|
SQL
|
|
|
|
TIME_TO_FIRST_RESPONSE_TOTAL_SQL = <<-SQL
|
|
SELECT AVG(t.hours)::float AS "hours"
|
|
FROM (
|
|
SELECT t.id, EXTRACT(EPOCH FROM MIN(p.created_at) - t.created_at)::float / 3600.0 AS "hours"
|
|
FROM topics t
|
|
LEFT JOIN posts p ON p.topic_id = t.id
|
|
/*where*/
|
|
GROUP BY t.id
|
|
) t
|
|
SQL
|
|
|
|
def self.time_to_first_response(sql, opts = nil)
|
|
opts ||= {}
|
|
builder = DB.build(sql)
|
|
builder.where("t.created_at >= :start_date", start_date: opts[:start_date]) if opts[:start_date]
|
|
builder.where("t.created_at < :end_date", end_date: opts[:end_date]) if opts[:end_date]
|
|
if opts[:category_id]
|
|
if opts[:include_subcategories]
|
|
builder.where("t.category_id IN (?)", Category.subcategory_ids(opts[:category_id]))
|
|
else
|
|
builder.where("t.category_id = ?", opts[:category_id])
|
|
end
|
|
end
|
|
builder.where("t.archetype <> '#{Archetype.private_message}'")
|
|
builder.where("t.deleted_at IS NULL")
|
|
builder.where("p.deleted_at IS NULL")
|
|
builder.where("p.post_number > 1")
|
|
builder.where("p.user_id != t.user_id")
|
|
builder.where("p.user_id in (:user_ids)", user_ids: opts[:user_ids]) if opts[:user_ids]
|
|
builder.where("p.post_type = :post_type", post_type: Post.types[:regular])
|
|
builder.where("EXTRACT(EPOCH FROM p.created_at - t.created_at) > 0")
|
|
builder.query_hash
|
|
end
|
|
|
|
def self.time_to_first_response_per_day(start_date, end_date, opts = {})
|
|
time_to_first_response(
|
|
TIME_TO_FIRST_RESPONSE_SQL,
|
|
opts.merge(start_date: start_date, end_date: end_date),
|
|
)
|
|
end
|
|
|
|
def self.time_to_first_response_total(opts = nil)
|
|
total = time_to_first_response(TIME_TO_FIRST_RESPONSE_TOTAL_SQL, opts)
|
|
total.first["hours"].to_f.round(2)
|
|
end
|
|
|
|
WITH_NO_RESPONSE_SQL = <<-SQL
|
|
SELECT COUNT(*) as count, tt.created_at AS "date"
|
|
FROM (
|
|
SELECT t.id, t.created_at::date AS created_at, MIN(p.post_number) first_reply
|
|
FROM topics t
|
|
LEFT JOIN posts p ON p.topic_id = t.id AND p.user_id != t.user_id AND p.deleted_at IS NULL AND p.post_type = #{Post.types[:regular]}
|
|
/*where*/
|
|
GROUP BY t.id
|
|
) tt
|
|
WHERE tt.first_reply IS NULL OR tt.first_reply < 2
|
|
GROUP BY tt.created_at
|
|
ORDER BY tt.created_at
|
|
SQL
|
|
|
|
def self.with_no_response_per_day(
|
|
start_date,
|
|
end_date,
|
|
category_id = nil,
|
|
include_subcategories = nil
|
|
)
|
|
builder = DB.build(WITH_NO_RESPONSE_SQL)
|
|
builder.where("t.created_at >= :start_date", start_date: start_date) if start_date
|
|
builder.where("t.created_at < :end_date", end_date: end_date) if end_date
|
|
if category_id
|
|
if include_subcategories
|
|
builder.where("t.category_id IN (?)", Category.subcategory_ids(category_id))
|
|
else
|
|
builder.where("t.category_id = ?", category_id)
|
|
end
|
|
end
|
|
builder.where("t.archetype <> '#{Archetype.private_message}'")
|
|
builder.where("t.deleted_at IS NULL")
|
|
builder.query_hash
|
|
end
|
|
|
|
WITH_NO_RESPONSE_TOTAL_SQL = <<-SQL
|
|
SELECT COUNT(*) as count
|
|
FROM (
|
|
SELECT t.id, MIN(p.post_number) first_reply
|
|
FROM topics t
|
|
LEFT JOIN posts p ON p.topic_id = t.id AND p.user_id != t.user_id AND p.deleted_at IS NULL AND p.post_type = #{Post.types[:regular]}
|
|
/*where*/
|
|
GROUP BY t.id
|
|
) tt
|
|
WHERE tt.first_reply IS NULL OR tt.first_reply < 2
|
|
SQL
|
|
|
|
def self.with_no_response_total(opts = {})
|
|
builder = DB.build(WITH_NO_RESPONSE_TOTAL_SQL)
|
|
if opts[:category_id]
|
|
if opts[:include_subcategories]
|
|
builder.where("t.category_id IN (?)", Category.subcategory_ids(opts[:category_id]))
|
|
else
|
|
builder.where("t.category_id = ?", opts[:category_id])
|
|
end
|
|
end
|
|
builder.where("t.archetype <> '#{Archetype.private_message}'")
|
|
builder.where("t.deleted_at IS NULL")
|
|
builder.query_single.first.to_i
|
|
end
|
|
|
|
def convert_to_public_topic(user, category_id: nil)
|
|
TopicConverter.new(self, user).convert_to_public_topic(category_id)
|
|
end
|
|
|
|
def convert_to_private_message(user)
|
|
TopicConverter.new(self, user).convert_to_private_message
|
|
end
|
|
|
|
def update_excerpt(excerpt)
|
|
update_column(:excerpt, excerpt)
|
|
ApplicationController.banner_json_cache.clear if archetype == "banner"
|
|
end
|
|
|
|
def pm_with_non_human_user?
|
|
sql = <<~SQL
|
|
SELECT 1 FROM topics
|
|
LEFT JOIN topic_allowed_groups ON topics.id = topic_allowed_groups.topic_id
|
|
WHERE topic_allowed_groups.topic_id IS NULL
|
|
AND topics.archetype = :private_message
|
|
AND topics.id = :topic_id
|
|
AND (
|
|
SELECT COUNT(*) FROM topic_allowed_users
|
|
WHERE topic_allowed_users.topic_id = :topic_id
|
|
AND topic_allowed_users.user_id > 0
|
|
) = 1
|
|
SQL
|
|
|
|
result = DB.exec(sql, private_message: Archetype.private_message, topic_id: self.id)
|
|
result != 0
|
|
end
|
|
|
|
def featured_link_root_domain
|
|
MiniSuffix.domain(UrlHelper.encode_and_parse(self.featured_link).hostname)
|
|
end
|
|
|
|
def self.private_message_topics_count_per_day(start_date, end_date, topic_subtype)
|
|
private_messages
|
|
.with_subtype(topic_subtype)
|
|
.where("topics.created_at >= ? AND topics.created_at <= ?", start_date, end_date)
|
|
.group("date(topics.created_at)")
|
|
.order("date(topics.created_at)")
|
|
.count
|
|
end
|
|
|
|
def is_category_topic?
|
|
@is_category_topic ||= Category.exists?(topic_id: self.id.to_i)
|
|
end
|
|
|
|
def reset_bumped_at(post_id = nil)
|
|
post =
|
|
(
|
|
if post_id
|
|
Post.find_by(id: post_id)
|
|
else
|
|
ordered_posts.where(
|
|
user_deleted: false,
|
|
hidden: false,
|
|
post_type: Post.types[:regular],
|
|
).last || first_post
|
|
end
|
|
)
|
|
|
|
return if !post
|
|
|
|
self.bumped_at = post.created_at
|
|
self.save(validate: false)
|
|
end
|
|
|
|
def auto_close_threshold_reached?
|
|
return if user&.staff?
|
|
|
|
scores =
|
|
ReviewableScore
|
|
.pending
|
|
.joins(:reviewable)
|
|
.where("reviewable_scores.score >= ?", Reviewable.min_score_for_priority)
|
|
.where("reviewables.topic_id = ?", self.id)
|
|
.pluck(
|
|
"COUNT(DISTINCT reviewable_scores.user_id), COALESCE(SUM(reviewable_scores.score), 0.0)",
|
|
)
|
|
.first
|
|
|
|
scores[0] >= SiteSetting.num_flaggers_to_close_topic &&
|
|
scores[1] >= Reviewable.score_to_auto_close_topic
|
|
end
|
|
|
|
def update_category_topic_count_by(num)
|
|
if category_id.present?
|
|
Category
|
|
.where("id = ?", category_id)
|
|
.where("topic_id != ? OR topic_id IS NULL", self.id)
|
|
.update_all("topic_count = topic_count + #{num.to_i}")
|
|
end
|
|
end
|
|
|
|
def access_topic_via_group
|
|
Group
|
|
.joins(:category_groups)
|
|
.where("category_groups.category_id = ?", self.category_id)
|
|
.where("groups.public_admission OR groups.allow_membership_requests")
|
|
.order(:allow_membership_requests)
|
|
.first
|
|
end
|
|
|
|
def incoming_email_addresses(group: nil, received_before: Time.zone.now)
|
|
email_addresses = Set.new
|
|
|
|
self
|
|
.incoming_email
|
|
.where("created_at <= ?", received_before)
|
|
.each do |incoming_email|
|
|
to_addresses = incoming_email.to_addresses_split
|
|
cc_addresses = incoming_email.cc_addresses_split
|
|
combined_addresses = [to_addresses, cc_addresses].flatten
|
|
|
|
# We only care about the emails addressed to the group or CC'd to the
|
|
# group if the group is present. If combined addresses is empty we do
|
|
# not need to do this check, and instead can proceed on to adding the
|
|
# from address.
|
|
#
|
|
# Will not include test1@gmail.com if the only IncomingEmail
|
|
# is:
|
|
#
|
|
# from: test1@gmail.com
|
|
# to: test+support@discoursemail.com
|
|
#
|
|
# Because we don't care about the from addresses and also the to address
|
|
# is not the email_username, which will be something like test1@gmail.com.
|
|
if group.present? && combined_addresses.any?
|
|
next if combined_addresses.none? { |address| address =~ group.email_username_regex }
|
|
end
|
|
|
|
email_addresses.add(incoming_email.from_address)
|
|
email_addresses.merge(combined_addresses)
|
|
end
|
|
|
|
email_addresses.subtract([nil, ""])
|
|
email_addresses.delete(group.email_username) if group.present?
|
|
|
|
email_addresses.to_a
|
|
end
|
|
|
|
def create_invite_notification!(target_user, notification_type, invited_by, post_number: 1)
|
|
if UserCommScreener.new(
|
|
acting_user: invited_by,
|
|
target_user_ids: target_user.id,
|
|
).ignoring_or_muting_actor?(target_user.id)
|
|
raise NotAllowed.new(I18n.t("not_accepting_pms", username: target_user.username))
|
|
end
|
|
|
|
target_user.notifications.create!(
|
|
notification_type: notification_type,
|
|
topic_id: self.id,
|
|
post_number: post_number,
|
|
data: {
|
|
topic_title: self.title,
|
|
display_username: invited_by.username,
|
|
original_user_id: user.id,
|
|
original_username: user.username,
|
|
}.to_json,
|
|
)
|
|
end
|
|
|
|
def rate_limit_topic_invitation(invited_by)
|
|
RateLimiter.new(
|
|
invited_by,
|
|
"topic-invitations-per-day",
|
|
SiteSetting.max_topic_invitations_per_day,
|
|
1.day.to_i,
|
|
).performed!
|
|
|
|
RateLimiter.new(
|
|
invited_by,
|
|
"topic-invitations-per-minute",
|
|
SiteSetting.max_topic_invitations_per_minute,
|
|
1.day.to_i,
|
|
).performed!
|
|
end
|
|
|
|
def cannot_permanently_delete_reason(user)
|
|
all_posts_count =
|
|
Post
|
|
.with_deleted
|
|
.where(topic_id: self.id)
|
|
.where(
|
|
post_type: [Post.types[:regular], Post.types[:moderator_action], Post.types[:whisper]],
|
|
)
|
|
.count
|
|
|
|
if posts_count > 0 || all_posts_count > 1
|
|
I18n.t("post.cannot_permanently_delete.many_posts")
|
|
elsif self.deleted_by_id == user&.id && self.deleted_at >= Post::PERMANENT_DELETE_TIMER.ago
|
|
time_left =
|
|
RateLimiter.time_left(
|
|
Post::PERMANENT_DELETE_TIMER.to_i - Time.zone.now.to_i + self.deleted_at.to_i,
|
|
)
|
|
I18n.t("post.cannot_permanently_delete.wait_or_different_admin", time_left: time_left)
|
|
end
|
|
end
|
|
|
|
def first_smtp_enabled_group
|
|
self.allowed_groups.where(smtp_enabled: true).first
|
|
end
|
|
|
|
def secure_audience_publish_messages
|
|
target_audience = {}
|
|
|
|
if private_message?
|
|
target_audience[:user_ids] = User.human_users.where("admin OR moderator").pluck(:id)
|
|
target_audience[:user_ids] |= allowed_users.pluck(:id)
|
|
target_audience[:user_ids] |= allowed_group_users.pluck(:id)
|
|
else
|
|
target_audience[:group_ids] = secure_group_ids
|
|
end
|
|
|
|
target_audience
|
|
end
|
|
|
|
def self.publish_stats_to_clients!(topic_id, type, opts = {})
|
|
topic = Topic.find_by(id: topic_id)
|
|
return if topic.blank?
|
|
|
|
case type
|
|
when :liked, :unliked
|
|
stats = { like_count: topic.like_count }
|
|
when :created, :destroyed, :deleted, :recovered
|
|
stats = {
|
|
posts_count: topic.posts_count,
|
|
last_posted_at: topic.last_posted_at.as_json,
|
|
last_poster: BasicUserSerializer.new(topic.last_poster, root: false).as_json,
|
|
}
|
|
else
|
|
stats = nil
|
|
end
|
|
|
|
if stats
|
|
secure_audience = topic.secure_audience_publish_messages
|
|
|
|
if secure_audience[:user_ids] != [] && secure_audience[:group_ids] != []
|
|
message = stats.merge({ id: topic_id, updated_at: Time.now, type: :stats })
|
|
MessageBus.publish("/topic/#{topic_id}", message, opts.merge(secure_audience))
|
|
end
|
|
end
|
|
end
|
|
|
|
def group_pm?
|
|
private_message? && all_allowed_users.count > 2
|
|
end
|
|
|
|
def visible_tags(guardian)
|
|
tags.reject { |tag| guardian.hidden_tag_names.include?(tag[:name]) }
|
|
end
|
|
|
|
def self.editable_custom_fields(guardian)
|
|
fields = []
|
|
fields.push(*DiscoursePluginRegistry.public_editable_topic_custom_fields)
|
|
fields.push(*DiscoursePluginRegistry.staff_editable_topic_custom_fields) if guardian.is_staff?
|
|
fields
|
|
end
|
|
|
|
private
|
|
|
|
def invite_to_private_message(invited_by, target_user, guardian)
|
|
if !guardian.can_send_private_message?(target_user)
|
|
raise UserExists.new(I18n.t("activerecord.errors.models.topic.attributes.base.cant_send_pm"))
|
|
end
|
|
|
|
rate_limit_topic_invitation(invited_by)
|
|
|
|
Topic.transaction do
|
|
unless topic_allowed_users.exists?(user_id: target_user.id)
|
|
topic_allowed_users.create!(user_id: target_user.id)
|
|
end
|
|
|
|
user_in_allowed_group = (user.group_ids & topic_allowed_groups.map(&:group_id)).present?
|
|
add_small_action(invited_by, "invited_user", target_user.username) if !user_in_allowed_group
|
|
|
|
create_invite_notification!(
|
|
target_user,
|
|
Notification.types[:invited_to_private_message],
|
|
invited_by,
|
|
)
|
|
end
|
|
end
|
|
|
|
def invite_to_topic(invited_by, target_user, group_ids, guardian)
|
|
Topic.transaction do
|
|
rate_limit_topic_invitation(invited_by)
|
|
|
|
if group_ids.present?
|
|
(
|
|
self.category.groups.where(id: group_ids).where(automatic: false) -
|
|
target_user.groups.where(automatic: false)
|
|
).each do |group|
|
|
if guardian.can_edit_group?(group)
|
|
group.add(target_user)
|
|
|
|
GroupActionLogger.new(invited_by, group).log_add_user_to_group(target_user)
|
|
end
|
|
end
|
|
end
|
|
|
|
if Guardian.new(target_user).can_see_topic?(self)
|
|
create_invite_notification!(target_user, Notification.types[:invited_to_topic], invited_by)
|
|
end
|
|
end
|
|
end
|
|
|
|
def limit_first_day_topics_per_day
|
|
apply_per_day_rate_limit_for("first-day-topics", :max_topics_in_first_day)
|
|
end
|
|
|
|
def apply_per_day_rate_limit_for(key, method_name)
|
|
RateLimiter.new(user, "#{key}-per-day", SiteSetting.get(method_name), 1.day.to_i)
|
|
end
|
|
end
|
|
|
|
# == Schema Information
|
|
#
|
|
# Table name: topics
|
|
#
|
|
# id :integer not null, primary key
|
|
# title :string not null
|
|
# last_posted_at :datetime
|
|
# created_at :datetime not null
|
|
# updated_at :datetime not null
|
|
# views :integer default(0), not null
|
|
# posts_count :integer default(0), not null
|
|
# user_id :integer
|
|
# last_post_user_id :integer not null
|
|
# reply_count :integer default(0), not null
|
|
# featured_user1_id :integer
|
|
# featured_user2_id :integer
|
|
# featured_user3_id :integer
|
|
# deleted_at :datetime
|
|
# highest_post_number :integer default(0), not null
|
|
# like_count :integer default(0), not null
|
|
# incoming_link_count :integer default(0), not null
|
|
# category_id :integer
|
|
# visible :boolean default(TRUE), not null
|
|
# moderator_posts_count :integer default(0), not null
|
|
# closed :boolean default(FALSE), not null
|
|
# archived :boolean default(FALSE), not null
|
|
# bumped_at :datetime not null
|
|
# has_summary :boolean default(FALSE), not null
|
|
# archetype :string default("regular"), not null
|
|
# featured_user4_id :integer
|
|
# notify_moderators_count :integer default(0), not null
|
|
# spam_count :integer default(0), not null
|
|
# pinned_at :datetime
|
|
# score :float
|
|
# percent_rank :float default(1.0), not null
|
|
# subtype :string
|
|
# slug :string
|
|
# deleted_by_id :integer
|
|
# participant_count :integer default(1)
|
|
# word_count :integer
|
|
# excerpt :string
|
|
# pinned_globally :boolean default(FALSE), not null
|
|
# pinned_until :datetime
|
|
# fancy_title :string
|
|
# highest_staff_post_number :integer default(0), not null
|
|
# featured_link :string
|
|
# reviewable_score :float default(0.0), not null
|
|
# image_upload_id :bigint
|
|
# slow_mode_seconds :integer default(0), not null
|
|
# bannered_until :datetime
|
|
# external_id :string
|
|
# visibility_reason_id :integer
|
|
#
|
|
# Indexes
|
|
#
|
|
# idx_topics_front_page (deleted_at,visible,archetype,category_id,id)
|
|
# idx_topics_user_id_deleted_at (user_id) WHERE (deleted_at IS NULL)
|
|
# idxtopicslug (slug) WHERE ((deleted_at IS NULL) AND (slug IS NOT NULL))
|
|
# index_topics_on_bannered_until (bannered_until) WHERE (bannered_until IS NOT NULL)
|
|
# index_topics_on_bumped_at_public (bumped_at) WHERE ((deleted_at IS NULL) AND ((archetype)::text <> 'private_message'::text))
|
|
# index_topics_on_created_at_and_visible (created_at,visible) WHERE ((deleted_at IS NULL) AND ((archetype)::text <> 'private_message'::text))
|
|
# index_topics_on_external_id (external_id) UNIQUE WHERE (external_id IS NOT NULL)
|
|
# index_topics_on_id_and_deleted_at (id,deleted_at)
|
|
# index_topics_on_id_filtered_banner (id) UNIQUE WHERE (((archetype)::text = 'banner'::text) AND (deleted_at IS NULL))
|
|
# index_topics_on_image_upload_id (image_upload_id)
|
|
# index_topics_on_lower_title (lower((title)::text))
|
|
# index_topics_on_pinned_at (pinned_at) WHERE (pinned_at IS NOT NULL)
|
|
# index_topics_on_pinned_globally (pinned_globally) WHERE pinned_globally
|
|
# index_topics_on_pinned_until (pinned_until) WHERE (pinned_until IS NOT NULL)
|
|
# index_topics_on_timestamps_private (bumped_at,created_at,updated_at) WHERE ((deleted_at IS NULL) AND ((archetype)::text = 'private_message'::text))
|
|
# index_topics_on_updated_at_public (updated_at,visible,highest_staff_post_number,highest_post_number,category_id,created_at,id) WHERE (((archetype)::text <> 'private_message'::text) AND (deleted_at IS NULL))
|
|
#
|