discourse/app/models/topic.rb
Robin Ward d23ef1d090 FIX: You could update a topic to have a title that's too short if the TextCleaner
removed extra characters. Additionally, updating the title will not return an error
message to the client app if the operation fails (rather than failing silently.)
2013-05-31 15:24:13 -04:00

667 lines
22 KiB
Ruby

require_dependency 'slug'
require_dependency 'avatar_lookup'
require_dependency 'topic_view'
require_dependency 'rate_limiter'
require_dependency 'text_sentinel'
require_dependency 'text_cleaner'
require_dependency 'trashable'
class Topic < ActiveRecord::Base
include ActionView::Helpers
include RateLimiter::OnCreateRecord
include Trashable
def self.max_sort_order
2**31 - 1
end
def self.featured_users_count
4
end
versioned if: :new_version_required?
def trash!
super
update_flagged_posts_count
end
def recover!
super
update_flagged_posts_count
end
rate_limit :default_rate_limiter
rate_limit :limit_topics_per_day
rate_limit :limit_private_messages_per_day
before_validation :sanitize_title
validates :title, :presence => true,
:length => { :in => SiteSetting.topic_title_length,
:allow_blank => true },
:quality_title => { :unless => :private_message? },
: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{ Topic.listable_topics } }
before_validation do
self.title = TextCleaner.clean_title(TextSentinel.title_sentinel(title).text) if errors[:title].empty?
end
serialize :meta_data, ActiveRecord::Coders::Hstore
belongs_to :category
has_many :posts
has_many :topic_allowed_users
has_many :topic_allowed_groups
has_many :allowed_group_users, through: :allowed_groups, source: :users
has_many :allowed_groups, through: :topic_allowed_groups, source: :group
has_many :allowed_users, through: :topic_allowed_users, source: :user
has_one :hot_topic
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
belongs_to :auto_close_user, class_name: 'User', foreign_key: :auto_close_user_id
has_many :topic_users
has_many :topic_links
has_many :topic_invites
has_many :invites, through: :topic_invites, source: :invite
# When we want to temporarily attach some data to a forum topic (usually before serialization)
attr_accessor :user_data
attr_accessor :posters # TODO: can replace with posters_summary once we remove old list code
attr_accessor :topic_list
# The regular order
scope :topic_list_order, lambda { order('topics.bumped_at desc') }
# Return private message topics
scope :private_messages, lambda {
where(archetype: Archetype::private_message)
}
scope :listable_topics, lambda { 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('created_at > ?', time_ago) }
# Helps us limit how many favorites can be made in a day
class FavoriteLimiter < RateLimiter
def initialize(user)
super(user, "favorited:#{Date.today.to_s}", SiteSetting.max_favorites_per_day, 1.day.to_i)
end
end
before_create do
self.bumped_at ||= Time.now
self.last_post_user_id ||= user_id
if !@ignore_category_auto_close and self.category and self.category.auto_close_days and self.auto_close_at.nil?
self.auto_close_at = self.category.auto_close_days.days.from_now
self.auto_close_user = (self.user.staff? ? self.user : Discourse.system_user)
end
end
after_create do
changed_to_category(category)
notifier.created_topic! user_id
if archetype == Archetype.private_message
DraftSequence.next!(user, Draft::NEW_PRIVATE_MESSAGE)
else
DraftSequence.next!(user, Draft::NEW_TOPIC)
end
end
before_save do
if (auto_close_at_changed? and !auto_close_at_was.nil?) or (auto_close_user_id_changed? and auto_close_at)
Jobs.cancel_scheduled_job(:close_topic, {topic_id: id})
true
end
end
after_save do
if auto_close_at and (auto_close_at_changed? or auto_close_user_id_changed?)
Jobs.enqueue_at(auto_close_at, :close_topic, {topic_id: id, user_id: auto_close_user_id || user_id})
end
end
# all users (in groups or directly targetted) that are going to get the pm
def all_allowed_users
# TODO we should probably change this from 3 queries to 1
User.where('id in (?)', allowed_users.select('users.id').to_a + allowed_group_users.select('users.id').to_a)
end
# Additional rate limits on topics: per day and private messages per day
def limit_topics_per_day
RateLimiter.new(user, "topics-per-day:#{Date.today.to_s}", SiteSetting.max_topics_per_day, 1.day.to_i)
end
def limit_private_messages_per_day
return unless private_message?
RateLimiter.new(user, "pms-per-day:#{Date.today.to_s}", SiteSetting.max_private_messages_per_day, 1.day.to_i)
end
def fancy_title
return title unless SiteSetting.title_fancy_entities?
# We don't always have to require this, if fancy is disabled
# see: http://meta.discourse.org/t/pattern-for-defer-loading-gems-and-profiling-with-perftools-rb/4629
require 'redcarpet' unless defined? Redcarpet
Redcarpet::Render::SmartyPants.render(title)
end
def sanitize_title
self.title = sanitize(title.to_s, tags: [], attributes: []).strip.presence
end
def new_version_required?
title_changed? || category_id_changed?
end
# Returns new topics since a date for display in email digest.
def self.new_topics(since)
Topic
.visible
.where(closed: false, archived: false)
.created_since(since)
.listable_topics
.topic_list_order
.includes(:user)
.limit(5)
end
def update_meta_data(data)
self.meta_data = (self.meta_data || {}).merge(data.stringify_keys)
save
end
def reload(options=nil)
@post_numbers = nil
super(options)
end
def post_numbers
@post_numbers ||= posts.order(:post_number).pluck(:post_number)
end
def age_in_days
((Time.zone.now - created_at) / 1.day).round
end
def has_meta_data_boolean?(key)
meta_data_string(key) == 'true'
end
def meta_data_string(key)
return unless meta_data.present?
meta_data[key.to_s]
end
def self.listable_count_per_day(sinceDaysAgo=30)
listable_topics.where('created_at > ?', sinceDaysAgo.days.ago).group('date(created_at)').order('date(created_at)').count
end
def private_message?
archetype == Archetype.private_message
end
def links_grouped
exec_sql("SELECT ftl.url,
ft.title,
ftl.link_topic_id,
ftl.reflection,
ftl.internal,
MIN(ftl.user_id) AS user_id,
SUM(clicks) AS clicks
FROM topic_links AS ftl
LEFT OUTER JOIN topics AS ft ON ftl.link_topic_id = ft.id
WHERE ftl.topic_id = ?
GROUP BY ftl.url, ft.title, ftl.link_topic_id, ftl.reflection, ftl.internal
ORDER BY clicks DESC",
id).to_a
end
# Search for similar topics
def self.similar_to(title, raw)
return [] unless title.present?
return [] unless raw.present?
# For now, we only match on title. We'll probably add body later on, hence the API hook
Topic.select(sanitize_sql_array(["topics.*, similarity(topics.title, :title) AS similarity", title: title]))
.visible
.where(closed: false, archived: false)
.listable_topics
.limit(SiteSetting.max_similar_results)
.order('similarity desc')
.all
end
def update_status(status, enabled, user)
TopicStatusUpdate.new(self, user).update! status, enabled
end
# Atomically creates the next post number
def self.next_post_number(topic_id, reply = false)
highest = exec_sql("select coalesce(max(post_number),0) as max from posts where topic_id = ?", topic_id).first['max'].to_i
reply_sql = reply ? ", reply_count = reply_count + 1" : ""
result = exec_sql("UPDATE topics SET highest_post_number = ? + 1#{reply_sql}
WHERE id = ? RETURNING highest_post_number", highest, topic_id)
result.first['highest_post_number'].to_i
end
# If a post is deleted we have to update our highest post counters
def self.reset_highest(topic_id)
result = exec_sql "UPDATE topics
SET highest_post_number = (SELECT COALESCE(MAX(post_number), 0) FROM posts WHERE topic_id = :topic_id AND deleted_at IS NULL),
posts_count = (SELECT count(*) FROM posts WHERE deleted_at IS NULL AND topic_id = :topic_id)
WHERE id = :topic_id
RETURNING highest_post_number", topic_id: topic_id
highest_post_number = result.first['highest_post_number'].to_i
# Update the forum topic user records
exec_sql "UPDATE topic_users
SET last_read_post_number = CASE
WHEN last_read_post_number > :highest THEN :highest
ELSE last_read_post_number
END,
seen_post_count = CASE
WHEN seen_post_count > :highest THEN :highest
ELSE seen_post_count
END
WHERE topic_id = :topic_id",
highest: highest_post_number,
topic_id: topic_id
end
# This calculates the geometric mean of the posts and stores it with the topic
def self.calculate_avg_time
exec_sql("UPDATE topics
SET avg_time = x.gmean
FROM (SELECT topic_id,
round(exp(avg(ln(avg_time)))) AS gmean
FROM posts
GROUP BY topic_id) AS x
WHERE x.topic_id = topics.id")
end
def changed_to_category(cat)
return if cat.blank?
return if Category.where(topic_id: id).first.present?
Topic.transaction do
old_category = category
if category_id.present? && category_id != cat.id
Category.update_all 'topic_count = topic_count - 1', ['id = ?', category_id]
end
self.category_id = cat.id
save
CategoryFeaturedTopic.feature_topics_for(old_category)
Category.update_all 'topic_count = topic_count + 1', id: cat.id
CategoryFeaturedTopic.feature_topics_for(cat) unless old_category.try(:id) == cat.try(:id)
end
end
def add_moderator_post(user, text, opts={})
new_post = nil
Topic.transaction do
creator = PostCreator.new(user,
raw: text,
post_type: Post.types[:moderator_action],
no_bump: opts[:bump].blank?,
topic_id: self.id)
new_post = creator.create
increment!(:moderator_posts_count)
new_post
end
if new_post.present?
# If we are moving posts, we want to insert the moderator post where the previous posts were
# in the stream, not at the end.
new_post.update_attributes(post_number: opts[:post_number], sort_order: opts[:post_number]) if opts[:post_number].present?
# Grab any links that are present
TopicLink.extract_from(new_post)
end
new_post
end
# Changes the category to a new name
def change_category(name)
# If the category name is blank, reset the attribute
if name.blank?
if category_id.present?
CategoryFeaturedTopic.feature_topics_for(category)
Category.update_all 'topic_count = topic_count - 1', id: category_id
end
self.category_id = nil
save
return
end
cat = Category.where(name: name).first
return if cat == category
changed_to_category(cat)
end
def featured_user_ids
[featured_user1_id, featured_user2_id, featured_user3_id, featured_user4_id].uniq.compact
end
# Invite a user to the topic by username or email. Returns success/failure
def invite(invited_by, username_or_email)
if private_message?
# If the user exists, add them to the topic.
user = User.find_by_username_or_email(username_or_email).first
if user.present?
if topic_allowed_users.create!(user_id: user.id)
# Notify the user they've been invited
user.notifications.create(notification_type: Notification.types[:invited_to_private_message],
topic_id: id,
post_number: 1,
data: { topic_title: title,
display_username: invited_by.username }.to_json)
return true
end
elsif username_or_email =~ /^.+@.+$/
# If the user doesn't exist, but it looks like an email, invite the user by email.
return invite_by_email(invited_by, username_or_email)
end
else
# Success is whether the invite was created
return invite_by_email(invited_by, username_or_email).present?
end
false
end
# Invite a user by email and return the invite. Return the previously existing invite
# if already exists. Returns nil if the invite can't be created.
def invite_by_email(invited_by, email)
lower_email = Email.downcase(email)
invite = Invite.with_deleted.where('invited_by_id = ? and email = ?', invited_by.id, lower_email).first
if invite.blank?
invite = Invite.create(invited_by: invited_by, email: lower_email)
unless invite.valid?
# If the email already exists, grant permission to that user
if invite.email_already_exists and private_message?
user = User.where(email: lower_email).first
topic_allowed_users.create!(user_id: user.id)
end
return
end
end
# Recover deleted invites if we invite them again
invite.recover if invite.deleted_at.present?
topic_invites.create(invite_id: invite.id)
Jobs.enqueue(:invite_email, invite_id: invite.id)
invite
end
def max_post_number
posts.maximum(:post_number).to_i
end
def move_posts(moved_by, post_ids, opts)
post_mover = PostMover.new(self, moved_by, post_ids)
if opts[:destination_topic_id]
post_mover.to_topic opts[:destination_topic_id]
elsif opts[:title]
post_mover.to_new_topic opts[:title]
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_flagged_posts_count
PostAction.update_flagged_posts_count
end
def update_action_counts
PostActionType.types.keys.each do |type|
count_field = "#{type}_count"
update_column(count_field, Post.where(topic_id: id).sum(count_field))
end
end
# Chooses which topic users to feature
def feature_topic_users(args={})
reload
# Don't include the OP or the last poster
to_feature = posts.where('user_id NOT IN (?, ?)', user_id, last_post_user_id)
# Exclude a given post if supplied (in the case of deletes)
to_feature = to_feature.where("id <> ?", args[:except_post_id]) if args[:except_post_id].present?
# Clear the featured users by default
Topic.featured_users_count.times do |i|
send("featured_user#{i+1}_id=", nil)
end
# Assign the featured_user{x} columns
to_feature = to_feature.group(:user_id).order('count_all desc').limit(Topic.featured_users_count)
to_feature.count.keys.each_with_index do |user_id, i|
send("featured_user#{i+1}_id=", user_id)
end
save
end
def posters_summary(options = {})
@posters_summary ||= TopicPostersSummary.new(self, options).summary
end
# Enable/disable the star on the topic
def toggle_star(user, starred)
Topic.transaction do
TopicUser.change(user, id, {starred: starred}.merge( starred ? {starred_at: DateTime.now, unstarred_at: nil} : {unstarred_at: DateTime.now}))
# Update the star count
exec_sql "UPDATE topics
SET star_count = (SELECT COUNT(*)
FROM topic_users AS ftu
WHERE ftu.topic_id = topics.id
AND ftu.starred = true)
WHERE id = ?", id
if starred
FavoriteLimiter.new(user).performed!
else
FavoriteLimiter.new(user).rollback!
end
end
end
def self.starred_counts_per_day(sinceDaysAgo=30)
TopicUser.starred_since(sinceDaysAgo).by_date_starred.count
end
def slug
unless slug = read_attribute(:slug)
return '' unless title.present?
slug = Slug.for(title).presence || "topic"
if new_record?
write_attribute(:slug, slug)
else
update_column(:slug, slug)
end
end
slug
end
def title=(t)
slug = (Slug.for(t.to_s).presence || "topic")
write_attribute(:slug, slug)
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
"/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 relative_url(post_number=nil)
url = "/t/#{slug}/#{id}"
url << "/#{post_number}" if post_number.to_i > 1
url
end
def clear_pin_for(user)
return unless user.present?
TopicUser.change(user.id, id, cleared_pinned_at: Time.now)
end
def update_pinned(status)
update_column(:pinned_at, status ? Time.now : nil)
end
def draft_key
"#{Draft::EXISTING_TOPIC}#{id}"
end
def notifier
@topic_notifier ||= TopicNotifier.new(self)
end
# notification stuff
def notify_watch!(user)
notifier.watch! user
end
def notify_tracking!(user)
notifier.tracking! user
end
def notify_regular!(user)
notifier.regular! user
end
def notify_muted!(user)
notifier.muted! user
end
def muted?(user)
if user && user.id
notifier.muted?(user.id)
end
end
# Enable/disable the mute on the topic
def toggle_mute(user_id)
notifier.toggle_mute user_id
end
def auto_close_days=(num_days)
@ignore_category_auto_close = true
num_days = num_days.to_i
self.auto_close_at = (num_days > 0 ? num_days.days.from_now : nil)
end
def secure_category?
category && category.secure
end
end
# == Schema Information
#
# Table name: topics
#
# id :integer not null, primary key
# title :string(255) 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 not null
# 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
# avg_time :integer
# deleted_at :datetime
# highest_post_number :integer default(0), not null
# image_url :string(255)
# off_topic_count :integer default(0), not null
# like_count :integer default(0), not null
# incoming_link_count :integer default(0), not null
# bookmark_count :integer default(0), not null
# star_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_best_of :boolean default(FALSE), not null
# meta_data :hstore
# vote_count :integer default(0), not null
# archetype :string(255) default("regular"), not null
# featured_user4_id :integer
# notify_moderators_count :integer default(0), not null
# spam_count :integer default(0), not null
# illegal_count :integer default(0), not null
# inappropriate_count :integer default(0), not null
# pinned_at :datetime
# score :float
# percent_rank :float default(1.0), not null
# notify_user_count :integer default(0), not null
# subtype :string(255)
# slug :string(255)
# auto_close_at :datetime
# auto_close_user_id :integer
#
# Indexes
#
# idx_topics_user_id_deleted_at (user_id)
# index_forum_threads_on_bumped_at (bumped_at)
#