Class: Topic

Inherits:
ActiveRecord::Base
  • Object
show all
Extended by:
Forwardable
Includes:
HasCustomFields, LimitedEdit, RateLimiter::OnCreateRecord, Searchable, Trashable
Defined in:
app/models/topic.rb

Defined Under Namespace

Classes: NotAllowed, UserExists

Constant Summary collapse

EXTERNAL_ID_MAX_LENGTH =
50
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
MAX_SIMILAR_BODY_LENGTH =
200
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
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
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

Constants included from Searchable

Searchable::PRIORITIES

Constants included from HasCustomFields

HasCustomFields::CUSTOM_FIELDS_MAX_ITEMS, HasCustomFields::DEFAULT_FIELD_DESCRIPTOR

Instance Attribute Summary collapse

Attributes included from HasCustomFields

#preloaded_custom_fields

Class Method Summary collapse

Instance Method Summary collapse

Methods included from LimitedEdit

#edit_time_limit_expired?

Methods included from Trashable

#trashed?

Methods included from HasCustomFields

#clear_custom_fields, #create_singular, #custom_field_preloaded?, #custom_fields, #custom_fields=, #custom_fields_clean?, #custom_fields_fk, #custom_fields_preloaded?, #on_custom_fields_change, #save_custom_fields, #set_preloaded_custom_fields, #upsert_custom_fields

Methods included from RateLimiter::OnCreateRecord

#default_rate_limiter, #disable_rate_limits!, included

Instance Attribute Details

#advance_draftObject

Returns the value of attribute advance_draft.



395
396
397
# File 'app/models/topic.rb', line 395

def advance_draft
  @advance_draft
end

#allowed_group_idsObject

Returns the value of attribute allowed_group_ids.



32
33
34
# File 'app/models/topic.rb', line 32

def allowed_group_ids
  @allowed_group_ids
end

#allowed_user_idsObject

Returns the value of attribute allowed_user_ids.



32
33
34
# File 'app/models/topic.rb', line 32

def allowed_user_ids
  @allowed_user_ids
end

#category_user_dataObject

Returns the value of attribute category_user_data.



319
320
321
# File 'app/models/topic.rb', line 319

def category_user_data
  @category_user_data
end

#dismissedObject

Returns the value of attribute dismissed.



320
321
322
# File 'app/models/topic.rb', line 320

def dismissed
  @dismissed
end

#ignore_category_auto_closeObject

Returns the value of attribute ignore_category_auto_close.



393
394
395
# File 'app/models/topic.rb', line 393

def ignore_category_auto_close
  @ignore_category_auto_close
end

#import_modeObject

set to true to optimize creation and save for imports



327
328
329
# File 'app/models/topic.rb', line 327

def import_mode
  @import_mode
end

#include_last_posterObject

Returns the value of attribute include_last_poster.



326
327
328
# File 'app/models/topic.rb', line 326

def include_last_poster
  @include_last_poster
end

#includes_destination_categoryObject

Returns the value of attribute includes_destination_category.



32
33
34
# File 'app/models/topic.rb', line 32

def includes_destination_category
  @includes_destination_category
end

#participant_groupsObject

Returns the value of attribute participant_groups.



324
325
326
# File 'app/models/topic.rb', line 324

def participant_groups
  @participant_groups
end

#participantsObject

Returns the value of attribute participants.



323
324
325
# File 'app/models/topic.rb', line 323

def participants
  @participants
end

#postersObject

TODO: can replace with posters_summary once we remove old list code



322
323
324
# File 'app/models/topic.rb', line 322

def posters
  @posters
end

#skip_callbacksObject

Returns the value of attribute skip_callbacks.



394
395
396
# File 'app/models/topic.rb', line 394

def skip_callbacks
  @skip_callbacks
end

#tags_changedObject

Returns the value of attribute tags_changed.



32
33
34
# File 'app/models/topic.rb', line 32

def tags_changed
  @tags_changed
end

#topic_listObject

Returns the value of attribute topic_list.



325
326
327
# File 'app/models/topic.rb', line 325

def topic_list
  @topic_list
end

#user_dataObject

When we want to temporarily attach some data to a forum topic (usually before serialization)



318
319
320
# File 'app/models/topic.rb', line 318

def user_data
  @user_data
end

Class Method Details

.count_exceeds_minimum?Boolean

Returns:

  • (Boolean)


481
482
483
# File 'app/models/topic.rb', line 481

def self.count_exceeds_minimum?
  count > SiteSetting.minimum_topics_similar
end

.editable_custom_fields(guardian) ⇒ Object



2120
2121
2122
2123
2124
2125
# File 'app/models/topic.rb', line 2120

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

.ensure_consistency!Object



1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
# File 'app/models/topic.rb', line 1511

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

.fancy_title(title) ⇒ Object



529
530
531
532
533
# File 'app/models/topic.rb', line 529

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

.find_by_slug(slug) ⇒ Object



1421
1422
1423
1424
1425
1426
1427
1428
# File 'app/models/topic.rb', line 1421

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

.for_digest(user, since, opts = nil) ⇒ Object

Returns hot topics since a date for display in email digest.



557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
# File 'app/models/topic.rb', line 557

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

.has_flag_scopeObject



493
494
495
# File 'app/models/topic.rb', line 493

def self.has_flag_scope
  ReviewableFlaggedPost.pending_and_default_visible
end

.listable_count_per_day(start_date, end_date, category_id = nil, include_subcategories = false, group_ids = nil) ⇒ Object



667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
# File 'app/models/topic.rb', line 667

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

.max_fancy_title_lengthObject



34
35
36
# File 'app/models/topic.rb', line 34

def self.max_fancy_title_length
  400
end

.next_post_number(topic_id, opts = {}) ⇒ Object

Atomically creates the next post number



819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
# File 'app/models/topic.rb', line 819

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

.private_message_topics_count_per_day(start_date, end_date, topic_subtype) ⇒ Object



1902
1903
1904
1905
1906
1907
1908
1909
# File 'app/models/topic.rb', line 1902

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

.publish_stats_to_clients!(topic_id, type, opts = {}) ⇒ Object



2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
# File 'app/models/topic.rb', line 2085

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

.recent(max = 10) ⇒ Object



477
478
479
# File 'app/models/topic.rb', line 477

def self.recent(max = 10)
  Topic.listable_topics.visible.secured.order("created_at desc").limit(max)
end

.relative_url(id, slug, post_number = nil) ⇒ Object



1453
1454
1455
1456
1457
1458
1459
# File 'app/models/topic.rb', line 1453

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

.reset_all_highest!Object



856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
# File 'app/models/topic.rb', line 856

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

.reset_highest(topic_id) ⇒ Object

If a post is deleted we have to update our highest post counters and last post information



951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
# File 'app/models/topic.rb', line 951

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

.share_thumbnail_sizeObject



38
39
40
# File 'app/models/topic.rb', line 38

def self.share_thumbnail_size
  [1024, 1024]
end

.similar_to(title, raw, user = nil) ⇒ Object



711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
# File 'app/models/topic.rb', line 711

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

.thumbnail_sizesObject



42
43
44
# File 'app/models/topic.rb', line 42

def self.thumbnail_sizes
  [self.share_thumbnail_size] + DiscoursePluginRegistry.topic_thumbnail_sizes
end

.time_to_first_response(sql, opts = nil) ⇒ Object



1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
# File 'app/models/topic.rb', line 1771

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

.time_to_first_response_per_day(start_date, end_date, opts = {}) ⇒ Object



1794
1795
1796
1797
1798
1799
# File 'app/models/topic.rb', line 1794

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

.time_to_first_response_total(opts = nil) ⇒ Object



1801
1802
1803
1804
# File 'app/models/topic.rb', line 1801

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

.top_viewed(max = 10) ⇒ Object



473
474
475
# File 'app/models/topic.rb', line 473

def self.top_viewed(max = 10)
  Topic.listable_topics.visible.secured.order("views desc").limit(max)
end

.url(id, slug, post_number = nil) ⇒ Object



1443
1444
1445
1446
1447
# File 'app/models/topic.rb', line 1443

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

.visibility_reasonsObject



46
47
48
49
50
51
52
53
54
55
56
57
# File 'app/models/topic.rb', line 46

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

.visible_post_types(viewed_by = nil, include_moderator_actions: true) ⇒ Object



465
466
467
468
469
470
471
# File 'app/models/topic.rb', line 465

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

.with_no_response_per_day(start_date, end_date, category_id = nil, include_subcategories = nil) ⇒ Object



1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
# File 'app/models/topic.rb', line 1820

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(opts = {}) ⇒ Object



1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
# File 'app/models/topic.rb', line 1853

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

Instance Method Details

#access_topic_via_groupObject



1962
1963
1964
1965
1966
1967
1968
1969
# File 'app/models/topic.rb', line 1962

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

#acting_userObject



1693
1694
1695
# File 'app/models/topic.rb', line 1693

def acting_user
  @acting_user || user
end

#acting_user=(u) ⇒ Object



1697
1698
1699
# File 'app/models/topic.rb', line 1697

def acting_user=(u)
  @acting_user = u
end

#add_moderator_post(user, text, opts = nil) ⇒ Object



1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
# File 'app/models/topic.rb', line 1098

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

#add_small_action(user, action_code, who = nil, opts = {}) ⇒ Object



1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
# File 'app/models/topic.rb', line 1085

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

#advance_draft_sequenceObject



451
452
453
454
455
456
457
# File 'app/models/topic.rb', line 451

def advance_draft_sequence
  if self.private_message?
    DraftSequence.next!(user, Draft::NEW_PRIVATE_MESSAGE)
  else
    DraftSequence.next!(user, Draft::NEW_TOPIC)
  end
end

#age_in_minutesObject



663
664
665
# File 'app/models/topic.rb', line 663

def age_in_minutes
  ((Time.zone.now - created_at) / 1.minute).round
end

#all_allowed_usersObject

all users (in groups or directly targeted) that are going to get the pm



506
507
508
509
510
511
512
# File 'app/models/topic.rb', line 506

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

#auto_close_threshold_reached?Boolean

Returns:

  • (Boolean)


1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
# File 'app/models/topic.rb', line 1935

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


1387
1388
1389
1390
1391
# File 'app/models/topic.rb', line 1387

def banner
  post = self.ordered_posts.first

  { html: post.cooked, key: self.id, url: self.url }
end

#best_postObject



485
486
487
488
489
490
491
# File 'app/models/topic.rb', line 485

def best_post
  posts
    .where(post_type: Post.types[:regular], user_deleted: false)
    .order("score desc nulls last")
    .limit(1)
    .first
end

#cannot_permanently_delete_reason(user) ⇒ Object



2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
# File 'app/models/topic.rb', line 2046

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

#category_allows_unlimited_owner_edits_on_first_post?Boolean

Returns:

  • (Boolean)


1689
1690
1691
# File 'app/models/topic.rb', line 1689

def category_allows_unlimited_owner_edits_on_first_post?
  category && category.allow_unlimited_owner_edits_on_first_post?
end

#change_category_to_id(category_id) ⇒ Object



1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
# File 'app/models/topic.rb', line 1131

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

#changed_to_category(new_category) ⇒ Object



1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
# File 'app/models/topic.rb', line 1022

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

#clear_pin_for(user) ⇒ Object



1469
1470
1471
1472
# File 'app/models/topic.rb', line 1469

def clear_pin_for(user)
  return if user.blank?
  TopicUser.change(user.id, id, cleared_pinned_at: Time.now)
end

#convert_to_private_message(user) ⇒ Object



1871
1872
1873
# File 'app/models/topic.rb', line 1871

def convert_to_private_message(user)
  TopicConverter.new(self, user).convert_to_private_message
end

#convert_to_public_topic(user, category_id: nil) ⇒ Object



1867
1868
1869
# File 'app/models/topic.rb', line 1867

def convert_to_public_topic(user, category_id: nil)
  TopicConverter.new(self, user).convert_to_public_topic(category_id)
end

#create_invite_notification!(target_user, notification_type, invited_by, post_number: 1) ⇒ Object



2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
# File 'app/models/topic.rb', line 2009

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

#delete_topic_timer(status_type, by_user: Discourse.system_user) ⇒ Object



1573
1574
1575
1576
1577
1578
1579
# File 'app/models/topic.rb', line 1573

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

#draft_keyObject



1499
1500
1501
# File 'app/models/topic.rb', line 1499

def draft_key
  "#{Draft::EXISTING_TOPIC}#{id}"
end

#email_already_exists_for?(invite) ⇒ Boolean

Returns:

  • (Boolean)


1279
1280
1281
# File 'app/models/topic.rb', line 1279

def email_already_exists_for?(invite)
  invite.email_already_exists && private_message?
end

#ensure_topic_has_a_categoryObject



459
460
461
462
463
# File 'app/models/topic.rb', line 459

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

#expandable_first_post?Boolean

Returns:

  • (Boolean)


1710
1711
1712
# File 'app/models/topic.rb', line 1710

def expandable_first_post?
  SiteSetting.embed_truncate? && has_topic_embed?
end

#fancy_titleObject



535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
# File 'app/models/topic.rb', line 535

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


1898
1899
1900
# File 'app/models/topic.rb', line 1898

def featured_link_root_domain
  MiniSuffix.domain(UrlHelper.encode_and_parse(self.featured_link).hostname)
end


148
149
150
# File 'app/models/topic.rb', line 148

def featured_users
  @featured_users ||= TopicFeaturedUsers.new(self)
end

#filtered_topic_thumbnails(extra_sizes: []) ⇒ Object



67
68
69
70
71
72
73
74
75
# File 'app/models/topic.rb', line 67

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

#first_smtp_enabled_groupObject



2067
2068
2069
# File 'app/models/topic.rb', line 2067

def first_smtp_enabled_group
  self.allowed_groups.where(smtp_enabled: true).first
end

#generate_thumbnails!(extra_sizes: []) ⇒ Object



117
118
119
120
121
122
123
124
125
126
127
# File 'app/models/topic.rb', line 117

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

#grant_permission_to_user(lower_email) ⇒ Object



1283
1284
1285
1286
1287
1288
# File 'app/models/topic.rb', line 1283

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

#group_pm?Boolean

Returns:

  • (Boolean)


2112
2113
2114
# File 'app/models/topic.rb', line 2112

def group_pm?
  private_message? && all_allowed_users.count > 2
end

#has_flags?Boolean

Returns:

  • (Boolean)


497
498
499
# File 'app/models/topic.rb', line 497

def has_flags?
  self.class.has_flag_scope.exists?(topic_id: self.id)
end

#has_topic_embed?Boolean

Returns:

  • (Boolean)


1706
1707
1708
# File 'app/models/topic.rb', line 1706

def has_topic_embed?
  TopicEmbed.where(topic_id: id).exists?
end

#image_url(enqueue_if_missing: false) ⇒ Object



129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
# File 'app/models/topic.rb', line 129

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

#incoming_email_addresses(group: nil, received_before: Time.zone.now) ⇒ Object



1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
# File 'app/models/topic.rb', line 1971

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 [email protected] if the only IncomingEmail
      # is:
      #
      # from: [email protected]
      # to: [email protected]
      #
      # Because we don't care about the from addresses and also the to address
      # is not the email_username, which will be something like [email protected].
      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

#inherit_auto_close_from_category(timer_type: :close) ⇒ Object



1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
# File 'app/models/topic.rb', line 1529

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

#inherit_slow_mode_from_categoryObject



1523
1524
1525
1526
1527
# File 'app/models/topic.rb', line 1523

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

#initialize_default_valuesObject



446
447
448
449
# File 'app/models/topic.rb', line 446

def initialize_default_values
  self.bumped_at ||= Time.now
  self.last_post_user_id ||= user_id
end

#invite(invited_by, username_or_email, group_ids = nil, custom_message = nil) ⇒ Object



1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
# File 'app/models/topic.rb', line 1230

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

#invite_group(user, group, should_notify: true) ⇒ Object



1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
# File 'app/models/topic.rb', line 1189

def invite_group(user, group, should_notify: true)
  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
    add_small_action(user, "invited_group", group.name)
    if should_notify
      Jobs.enqueue(:post_alert, post_id: last_post.id)
      Jobs.enqueue(:group_pm_alert, user_id: user.id, group_id: group.id, post_id: last_post.id)
    end
  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

#is_category_topic?Boolean

Returns:

  • (Boolean)


1911
1912
1913
# File 'app/models/topic.rb', line 1911

def is_category_topic?
  @is_category_topic ||= Category.exists?(topic_id: self.id.to_i)
end

#is_official_warning?Boolean

Returns:

  • (Boolean)


501
502
503
# File 'app/models/topic.rb', line 501

def is_official_warning?
  subtype == TopicSubtype.moderator_warning
end

#last_post_urlObject

NOTE: These are probably better off somewhere else.

Having a model know about URLs seems a bit strange.


1439
1440
1441
# File 'app/models/topic.rb', line 1439

def last_post_url
  "#{Discourse.base_path}/t/#{slug}/#{id}/#{posts_count}"
end

#limit_private_messages_per_dayObject



524
525
526
527
# File 'app/models/topic.rb', line 524

def limit_private_messages_per_day
  return unless private_message?
  apply_per_day_rate_limit_for("pms", :max_personal_messages_per_day)
end

#limit_topics_per_dayObject

Additional rate limits on topics: per day and private messages per day



515
516
517
518
519
520
521
522
# File 'app/models/topic.rb', line 515

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

#make_banner!(user, bannered_until = nil) ⇒ Object



1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
# File 'app/models/topic.rb', line 1349

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

#max_post_numberObject



1290
1291
1292
# File 'app/models/topic.rb', line 1290

def max_post_number
  posts.with_deleted.maximum(:post_number).to_i
end

#message_archived?(user) ⇒ Boolean

Returns:

  • (Boolean)


1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
# File 'app/models/topic.rb', line 1714

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

#move_posts(moved_by, post_ids, opts) ⇒ Object



1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
# File 'app/models/topic.rb', line 1294

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",
      options: {
        freeze_original: opts[:freeze_original],
      },
    )

  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

#muted?(user) ⇒ Boolean

Returns:

  • (Boolean)


1507
1508
1509
# File 'app/models/topic.rb', line 1507

def muted?(user)
  notifier.muted?(user.id) if user && user.id
end

#notifierObject



1503
1504
1505
# File 'app/models/topic.rb', line 1503

def notifier
  @topic_notifier ||= TopicNotifier.new(self)
end

#open?Boolean

Returns:

  • (Boolean)


705
706
707
# File 'app/models/topic.rb', line 705

def open?
  !self.closed?
end

#participant_groups_summary(options = {}) ⇒ Object



1345
1346
1347
# File 'app/models/topic.rb', line 1345

def participant_groups_summary(options = {})
  @participant_groups_summary ||= TopicParticipantGroupsSummary.new(self, options).summary
end

#participants_summary(options = {}) ⇒ Object



1341
1342
1343
# File 'app/models/topic.rb', line 1341

def participants_summary(options = {})
  @participants_summary ||= TopicParticipantsSummary.new(self, options).summary
end

#pm_with_non_human_user?Boolean

Returns:

  • (Boolean)


1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
# File 'app/models/topic.rb', line 1880

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

#post_numbersObject



659
660
661
# File 'app/models/topic.rb', line 659

def post_numbers
  @post_numbers ||= posts.order(:post_number).pluck(:post_number)
end

#posters_summary(options = {}) ⇒ Object

avatar lookup in options



1337
1338
1339
# File 'app/models/topic.rb', line 1337

def posters_summary(options = {}) # avatar lookup in options
  @posters_summary ||= TopicPostersSummary.new(self, options).summary
end

#private_message?Boolean

Returns:

  • (Boolean)


697
698
699
# File 'app/models/topic.rb', line 697

def private_message?
  self.archetype == Archetype.private_message
end

#public_topic_timerObject



1565
1566
1567
# File 'app/models/topic.rb', line 1565

def public_topic_timer
  @public_topic_timer ||= topic_timers.find_by(public_type: true)
end

#rate_limit_topic_invitation(invited_by) ⇒ Object



2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
# File 'app/models/topic.rb', line 2030

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

#re_pin_for(user) ⇒ Object



1474
1475
1476
1477
# File 'app/models/topic.rb', line 1474

def re_pin_for(user)
  return if user.blank?
  TopicUser.change(user.id, id, cleared_pinned_at: nil)
end

#reached_recipients_limit?Boolean

Returns:

  • (Boolean)


1183
1184
1185
1186
1187
# File 'app/models/topic.rb', line 1183

def reached_recipients_limit?
  return false unless private_message?
  topic_allowed_users.count + topic_allowed_groups.count >=
    SiteSetting.max_allowed_message_recipients
end

#read_restricted_category?Boolean

Returns:

  • (Boolean)


1685
1686
1687
# File 'app/models/topic.rb', line 1685

def read_restricted_category?
  category && category.read_restricted
end

#recover!(recovered_by = nil) ⇒ Object



168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
# File 'app/models/topic.rb', line 168

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

#regular?Boolean

Returns:

  • (Boolean)


701
702
703
# File 'app/models/topic.rb', line 701

def regular?
  self.archetype == Archetype.default
end

#relative_url(post_number = nil) ⇒ Object



1465
1466
1467
# File 'app/models/topic.rb', line 1465

def relative_url(post_number = nil)
  Topic.relative_url(id, slug, post_number)
end

#reload(options = nil) ⇒ Object



651
652
653
654
655
656
657
# File 'app/models/topic.rb', line 651

def reload(options = nil)
  @post_numbers = nil
  @public_topic_timer = nil
  @slow_mode_topic_timer = nil
  @is_category_topic = nil
  super(options)
end

#remove_allowed_group(removed_by, name) ⇒ Object



1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
# File 'app/models/topic.rb', line 1148

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

#remove_allowed_user(removed_by, username) ⇒ Object



1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
# File 'app/models/topic.rb', line 1162

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

#remove_banner!(user) ⇒ Object



1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
# File 'app/models/topic.rb', line 1376

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

#reset_bumped_at(post_id = nil) ⇒ Object



1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
# File 'app/models/topic.rb', line 1915

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

#secure_audience_publish_messagesObject



2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
# File 'app/models/topic.rb', line 2071

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

#secure_group_idsObject



1701
1702
1703
1704
# File 'app/models/topic.rb', line 1701

def secure_group_ids
  @secure_group_ids ||=
    (self.category.secure_group_ids if self.category && self.category.read_restricted?)
end

#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) ⇒ Object

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.


1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
# File 'app/models/topic.rb', line 1593

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

#shared_draft?Boolean

Returns:

  • (Boolean)


59
60
61
# File 'app/models/topic.rb', line 59

def shared_draft?
  SharedDraft.exists?(topic_id: id)
end

#slow_mode_topic_timerObject



1569
1570
1571
# File 'app/models/topic.rb', line 1569

def slow_mode_topic_timer
  @slow_mode_topic_timer ||= topic_timers.find_by(status_type: TopicTimer.types[:clear_slow_mode])
end

#slugObject

Even if the slug column in the database is null, topic.slug will return something:



1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
# File 'app/models/topic.rb', line 1407

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

#slug_for_topic(title) ⇒ Object



1396
1397
1398
1399
1400
1401
1402
1403
1404
# File 'app/models/topic.rb', line 1396

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

#slugless_url(post_number = nil) ⇒ Object



1461
1462
1463
# File 'app/models/topic.rb', line 1461

def slugless_url(post_number = nil)
  Topic.relative_url(id, nil, post_number)
end

#thumbnail_info(enqueue_if_missing: false, extra_sizes: []) ⇒ Object



77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'app/models/topic.rb', line 77

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

#thumbnail_job_redis_key(sizes) ⇒ Object



63
64
65
# File 'app/models/topic.rb', line 63

def thumbnail_job_redis_key(sizes)
  "generate_topic_thumbnail_enqueue_#{id}_#{sizes.inspect}"
end

#title=(t) ⇒ Object



1430
1431
1432
1433
1434
1435
# File 'app/models/topic.rb', line 1430

def title=(t)
  slug = slug_for_topic(t.to_s)
  write_attribute(:slug, slug)
  write_attribute(:fancy_title, nil)
  write_attribute(:title, t)
end

#trash!(trashed_by = nil) ⇒ Object



152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# File 'app/models/topic.rb', line 152

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

#update_action_countsObject



1330
1331
1332
1333
1334
1335
# File 'app/models/topic.rb', line 1330

def update_action_counts
  update_column(
    :like_count,
    Post.where.not(post_type: Post.types[:whisper]).where(topic_id: id).sum(:like_count),
  )
end

#update_category_topic_count_by(num) ⇒ Object



1953
1954
1955
1956
1957
1958
1959
1960
# File 'app/models/topic.rb', line 1953

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

#update_excerpt(excerpt) ⇒ Object



1875
1876
1877
1878
# File 'app/models/topic.rb', line 1875

def update_excerpt(excerpt)
  update_column(:excerpt, excerpt)
  ApplicationController.banner_json_cache.clear if archetype == "banner"
end

#update_pinned(status, global = false, pinned_until = nil) ⇒ Object



1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
# File 'app/models/topic.rb', line 1479

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

#update_statisticsObject

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.



1324
1325
1326
1327
1328
# File 'app/models/topic.rb', line 1324

def update_statistics
  feature_topic_users
  update_action_counts
  Topic.reset_highest(id)
end

#update_status(status, enabled, user, opts = {}) ⇒ Object



798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
# File 'app/models/topic.rb', line 798

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

#url(post_number = nil) ⇒ Object



1449
1450
1451
# File 'app/models/topic.rb', line 1449

def url(post_number = nil)
  self.class.url id, slug, post_number
end

#visible_tags(guardian) ⇒ Object



2116
2117
2118
# File 'app/models/topic.rb', line 2116

def visible_tags(guardian)
  tags.reject { |tag| guardian.hidden_tag_names.include?(tag[:name]) }
end