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

Constants included from Searchable

Searchable::PRIORITIES

Constants included from HasCustomFields

HasCustomFields::CUSTOM_FIELDS_MAX_ITEMS, HasCustomFields::CUSTOM_FIELDS_MAX_VALUE_LENGTH

Instance Attribute Summary collapse

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_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.



368
369
370
# File 'app/models/topic.rb', line 368

def advance_draft
  @advance_draft
end

#allowed_group_idsObject

Returns the value of attribute allowed_group_ids.



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

def allowed_group_ids
  @allowed_group_ids
end

#allowed_user_idsObject

Returns the value of attribute allowed_user_ids.



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

def allowed_user_ids
  @allowed_user_ids
end

#category_user_dataObject

Returns the value of attribute category_user_data.



291
292
293
# File 'app/models/topic.rb', line 291

def category_user_data
  @category_user_data
end

#dismissedObject

Returns the value of attribute dismissed.



292
293
294
# File 'app/models/topic.rb', line 292

def dismissed
  @dismissed
end

#ignore_category_auto_closeObject

Returns the value of attribute ignore_category_auto_close.



366
367
368
# File 'app/models/topic.rb', line 366

def ignore_category_auto_close
  @ignore_category_auto_close
end

#import_modeObject

set to true to optimize creation and save for imports



300
301
302
# File 'app/models/topic.rb', line 300

def import_mode
  @import_mode
end

#include_last_posterObject

Returns the value of attribute include_last_poster.



299
300
301
# File 'app/models/topic.rb', line 299

def include_last_poster
  @include_last_poster
end

#includes_destination_categoryObject

Returns the value of attribute includes_destination_category.



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

def includes_destination_category
  @includes_destination_category
end

#meta_dataObject

Returns the value of attribute meta_data.



298
299
300
# File 'app/models/topic.rb', line 298

def 
  @meta_data
end

#participant_groupsObject

Returns the value of attribute participant_groups.



296
297
298
# File 'app/models/topic.rb', line 296

def participant_groups
  @participant_groups
end

#participantsObject

Returns the value of attribute participants.



295
296
297
# File 'app/models/topic.rb', line 295

def participants
  @participants
end

#postersObject

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



294
295
296
# File 'app/models/topic.rb', line 294

def posters
  @posters
end

#skip_callbacksObject

Returns the value of attribute skip_callbacks.



367
368
369
# File 'app/models/topic.rb', line 367

def skip_callbacks
  @skip_callbacks
end

#tags_changedObject

Returns the value of attribute tags_changed.



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

def tags_changed
  @tags_changed
end

#topic_listObject

Returns the value of attribute topic_list.



297
298
299
# File 'app/models/topic.rb', line 297

def topic_list
  @topic_list
end

#user_dataObject

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



290
291
292
# File 'app/models/topic.rb', line 290

def user_data
  @user_data
end

Class Method Details

.count_exceeds_minimum?Boolean

Returns:

  • (Boolean)


454
455
456
# File 'app/models/topic.rb', line 454

def self.count_exceeds_minimum?
  count > SiteSetting.minimum_topics_similar
end

.ensure_consistency!Object



1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
# File 'app/models/topic.rb', line 1463

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



502
503
504
505
506
# File 'app/models/topic.rb', line 502

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



1373
1374
1375
1376
1377
1378
1379
1380
# File 'app/models/topic.rb', line 1373

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.



530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
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
# File 'app/models/topic.rb', line 530

def self.for_digest(user, since, opts = nil)
  opts = 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 muted and shared draft categories
  remove_category_ids =
    CategoryUser.where(
      user_id: user.id,
      notification_level: CategoryUser.notification_levels[:muted],
    ).pluck(:category_id)
  if SiteSetting.digest_suppress_categories.present?
    topics =
      topics.where(
        "topics.category_id NOT IN (?)",
        SiteSetting.digest_suppress_categories.split("|").map(&:to_i),
      )
  end
  if SiteSetting.digest_suppress_tags.present?
    tag_ids = Tag.where_name(SiteSetting.digest_suppress_tags.split("|")).pluck(:id)
    if tag_ids.present?
      topics =
        topics.joins("LEFT JOIN topic_tags tg ON topics.id = tg.topic_id").where(
          "tg.tag_id NOT IN (?) OR tg.tag_id IS NULL",
          tag_ids,
        )
    end
  end
  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



466
467
468
# File 'app/models/topic.rb', line 466

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



653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
# File 'app/models/topic.rb', line 653

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



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

def self.max_fancy_title_length
  400
end

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

Atomically creates the next post number



803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
# File 'app/models/topic.rb', line 803

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



1861
1862
1863
1864
1865
1866
1867
1868
# File 'app/models/topic.rb', line 1861

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



2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
# File 'app/models/topic.rb', line 2036

def self.publish_stats_to_clients!(topic_id, type, opts = {})
  topic = Topic.find_by(id: topic_id)
  return unless topic.present?

  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



450
451
452
# File 'app/models/topic.rb', line 450

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



1405
1406
1407
1408
1409
1410
1411
# File 'app/models/topic.rb', line 1405

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



840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
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
# File 'app/models/topic.rb', line 840

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
    )
    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
    FROM X, Y
    WHERE
      topics.archetype <> 'private_message' AND
      X.topic_id = topics.id AND
      Y.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
      )
  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
    )
    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
    FROM X, Y
    WHERE
      topics.archetype = 'private_message' AND
      X.topic_id = topics.id AND
      Y.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
      )
  SQL
end

.reset_highest(topic_id) ⇒ Object

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



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
949
950
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
# File 'app/models/topic.rb', line 915

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}
      ),
      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



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

def self.share_thumbnail_size
  [1024, 1024]
end

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



697
698
699
700
701
702
703
704
705
706
707
708
709
710
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
# File 'app/models/topic.rb', line 697

def self.similar_to(title, raw, user = nil)
  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



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

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

.time_to_first_response(sql, opts = nil) ⇒ Object



1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
# File 'app/models/topic.rb', line 1723

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



1746
1747
1748
1749
1750
1751
# File 'app/models/topic.rb', line 1746

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



1753
1754
1755
1756
# File 'app/models/topic.rb', line 1753

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



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

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

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



1395
1396
1397
1398
1399
# File 'app/models/topic.rb', line 1395

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

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



438
439
440
441
442
443
444
# File 'app/models/topic.rb', line 438

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



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

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



1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
# File 'app/models/topic.rb', line 1805

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



1913
1914
1915
1916
1917
1918
1919
1920
# File 'app/models/topic.rb', line 1913

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



1645
1646
1647
# File 'app/models/topic.rb', line 1645

def acting_user
  @acting_user || user
end

#acting_user=(u) ⇒ Object



1649
1650
1651
# File 'app/models/topic.rb', line 1649

def acting_user=(u)
  @acting_user = u
end

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



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
1084
1085
1086
# File 'app/models/topic.rb', line 1055

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



1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
# File 'app/models/topic.rb', line 1042

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



424
425
426
427
428
429
430
# File 'app/models/topic.rb', line 424

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



649
650
651
# File 'app/models/topic.rb', line 649

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



479
480
481
482
483
484
485
# File 'app/models/topic.rb', line 479

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)


1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
# File 'app/models/topic.rb', line 1886

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


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

def banner
  post = self.ordered_posts.first

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

#best_postObject



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

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



1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
# File 'app/models/topic.rb', line 1997

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)


1641
1642
1643
# File 'app/models/topic.rb', line 1641

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



1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
# File 'app/models/topic.rb', line 1088

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



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
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
# File 'app/models/topic.rb', line 979

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



1421
1422
1423
1424
# File 'app/models/topic.rb', line 1421

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

#convert_to_private_message(user) ⇒ Object



1826
1827
1828
1829
1830
1831
1832
# File 'app/models/topic.rb', line 1826

def convert_to_private_message(user)
  read_restricted = category.read_restricted
  private_topic = TopicConverter.new(self, user).convert_to_private_message
  Tag.update_counters(private_topic.tags, { public_topic_count: -1 }) if !read_restricted
  add_small_action(user, "private_topic") if private_topic
  private_topic
end

#convert_to_public_topic(user, category_id: nil) ⇒ Object



1819
1820
1821
1822
1823
1824
# File 'app/models/topic.rb', line 1819

def convert_to_public_topic(user, category_id: nil)
  public_topic = TopicConverter.new(self, user).convert_to_public_topic(category_id)
  Tag.update_counters(public_topic.tags, { public_topic_count: 1 }) if !category.read_restricted
  add_small_action(user, "public_topic") if public_topic
  public_topic
end

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



1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
# File 'app/models/topic.rb', line 1960

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



1525
1526
1527
1528
1529
1530
1531
# File 'app/models/topic.rb', line 1525

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



1451
1452
1453
# File 'app/models/topic.rb', line 1451

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

#email_already_exists_for?(invite) ⇒ Boolean

Returns:

  • (Boolean)


1234
1235
1236
# File 'app/models/topic.rb', line 1234

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

#ensure_topic_has_a_categoryObject



432
433
434
435
436
# File 'app/models/topic.rb', line 432

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)


1662
1663
1664
# File 'app/models/topic.rb', line 1662

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

#fancy_titleObject



508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
# File 'app/models/topic.rb', line 508

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


1857
1858
1859
# File 'app/models/topic.rb', line 1857

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


130
131
132
# File 'app/models/topic.rb', line 130

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

#filtered_topic_thumbnails(extra_sizes: []) ⇒ Object



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

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



2018
2019
2020
# File 'app/models/topic.rb', line 2018

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

#generate_thumbnails!(extra_sizes: []) ⇒ Object



99
100
101
102
103
104
105
106
107
108
109
# File 'app/models/topic.rb', line 99

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



1238
1239
1240
1241
1242
1243
# File 'app/models/topic.rb', line 1238

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)


2063
2064
2065
# File 'app/models/topic.rb', line 2063

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

#has_flags?Boolean

Returns:

  • (Boolean)


470
471
472
# File 'app/models/topic.rb', line 470

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

#has_topic_embed?Boolean

Returns:

  • (Boolean)


1658
1659
1660
# File 'app/models/topic.rb', line 1658

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

#image_url(enqueue_if_missing: false) ⇒ Object



111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
# File 'app/models/topic.rb', line 111

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



1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
# File 'app/models/topic.rb', line 1922

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



1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
# File 'app/models/topic.rb', line 1481

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



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

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



419
420
421
422
# File 'app/models/topic.rb', line 419

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



1185
1186
1187
1188
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
1229
1230
1231
1232
# File 'app/models/topic.rb', line 1185

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



1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
# File 'app/models/topic.rb', line 1146

def invite_group(user, group)
  TopicAllowedGroup.create!(topic_id: self.id, group_id: group.id)
  self.allowed_groups.reload

  last_post =
    self.posts.order("post_number desc").where("not hidden AND posts.deleted_at IS NULL").first
  if last_post
    Jobs.enqueue(:post_alert, post_id: last_post.id)
    add_small_action(user, "invited_group", group.name)
    Jobs.enqueue(:group_pm_alert, user_id: user.id, group_id: group.id, post_id: last_post.id)
  end

  # If the group invited includes the OP of the topic as one of is members,
  # we cannot strip the topic_allowed_user record since it will be more
  # complicated to recover the topic_allowed_user record for the OP if the
  # group is removed.
  allowed_user_where_clause = <<~SQL
    users.id IN (
      SELECT topic_allowed_users.user_id
      FROM topic_allowed_users
      INNER JOIN group_users ON group_users.user_id = topic_allowed_users.user_id
      INNER JOIN topic_allowed_groups ON topic_allowed_groups.group_id = group_users.group_id
      WHERE topic_allowed_groups.group_id = :group_id AND
            topic_allowed_users.topic_id = :topic_id AND
            topic_allowed_users.user_id != :op_user_id
    )
  SQL
  User
    .where(
      [
        allowed_user_where_clause,
        { group_id: group.id, topic_id: self.id, op_user_id: self.user_id },
      ],
    )
    .find_each { |allowed_user| remove_allowed_user(Discourse.system_user, allowed_user) }

  true
end

#is_category_topic?Boolean

Returns:

  • (Boolean)


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

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

#is_official_warning?Boolean

Returns:

  • (Boolean)


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

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.


1391
1392
1393
# File 'app/models/topic.rb', line 1391

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

#limit_private_messages_per_dayObject



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

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



488
489
490
491
492
493
494
495
# File 'app/models/topic.rb', line 488

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



1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
# File 'app/models/topic.rb', line 1301

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



1245
1246
1247
# File 'app/models/topic.rb', line 1245

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

#message_archived?(user) ⇒ Boolean

Returns:

  • (Boolean)


1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
# File 'app/models/topic.rb', line 1666

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



1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
# File 'app/models/topic.rb', line 1249

def move_posts(moved_by, post_ids, opts)
  post_mover =
    PostMover.new(
      self,
      moved_by,
      post_ids,
      move_to_pm: opts[:archetype].present? && opts[:archetype] == "private_message",
    )

  if opts[:destination_topic_id]
    topic =
      post_mover.to_topic(
        opts[:destination_topic_id],
        **opts.slice(:participants, :chronological_order),
      )

    DiscourseEvent.trigger(:topic_merged, post_mover.original_topic, post_mover.destination_topic)

    topic
  elsif opts[:title]
    post_mover.to_new_topic(opts[:title], opts[:category_id], opts[:tags])
  end
end

#muted?(user) ⇒ Boolean

Returns:

  • (Boolean)


1459
1460
1461
# File 'app/models/topic.rb', line 1459

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

#notifierObject



1455
1456
1457
# File 'app/models/topic.rb', line 1455

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

#open?Boolean

Returns:

  • (Boolean)


691
692
693
# File 'app/models/topic.rb', line 691

def open?
  !self.closed?
end

#participant_groups_summary(options = {}) ⇒ Object



1297
1298
1299
# File 'app/models/topic.rb', line 1297

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

#participants_summary(options = {}) ⇒ Object



1293
1294
1295
# File 'app/models/topic.rb', line 1293

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

#pm_with_non_human_user?Boolean

Returns:

  • (Boolean)


1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
# File 'app/models/topic.rb', line 1839

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



645
646
647
# File 'app/models/topic.rb', line 645

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

#posters_summary(options = {}) ⇒ Object

avatar lookup in options



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

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

#private_message?Boolean

Returns:

  • (Boolean)


683
684
685
# File 'app/models/topic.rb', line 683

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

#public_topic_timerObject



1517
1518
1519
# File 'app/models/topic.rb', line 1517

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

#rate_limit_topic_invitation(invited_by) ⇒ Object



1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
# File 'app/models/topic.rb', line 1981

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



1426
1427
1428
1429
# File 'app/models/topic.rb', line 1426

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

#reached_recipients_limit?Boolean

Returns:

  • (Boolean)


1140
1141
1142
1143
1144
# File 'app/models/topic.rb', line 1140

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)


1637
1638
1639
# File 'app/models/topic.rb', line 1637

def read_restricted_category?
  category && category.read_restricted
end

#recover!(recovered_by = nil) ⇒ Object



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

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)


687
688
689
# File 'app/models/topic.rb', line 687

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

#relative_url(post_number = nil) ⇒ Object



1417
1418
1419
# File 'app/models/topic.rb', line 1417

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

#reload(options = nil) ⇒ Object



637
638
639
640
641
642
643
# File 'app/models/topic.rb', line 637

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



1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
# File 'app/models/topic.rb', line 1105

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



1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
# File 'app/models/topic.rb', line 1119

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



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

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_atObject



1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
# File 'app/models/topic.rb', line 1874

def reset_bumped_at
  post =
    ordered_posts.where(
      user_deleted: false,
      hidden: false,
      post_type: Post.types[:regular],
    ).last || first_post

  self.bumped_at = post.created_at
  self.save(validate: false)
end

#secure_audience_publish_messagesObject



2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
# File 'app/models/topic.rb', line 2022

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



1653
1654
1655
1656
# File 'app/models/topic.rb', line 1653

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.


1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
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
# File 'app/models/topic.rb', line 1545

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

#slow_mode_topic_timerObject



1521
1522
1523
# File 'app/models/topic.rb', line 1521

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:



1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
# File 'app/models/topic.rb', line 1359

def slug
  unless slug = read_attribute(:slug)
    return "" unless title.present?
    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



1348
1349
1350
1351
1352
1353
1354
1355
1356
# File 'app/models/topic.rb', line 1348

def slug_for_topic(title)
  return "" unless title.present?
  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



1413
1414
1415
# File 'app/models/topic.rb', line 1413

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

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



59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# File 'app/models/topic.rb', line 59

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.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



45
46
47
# File 'app/models/topic.rb', line 45

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

#title=(t) ⇒ Object



1382
1383
1384
1385
1386
1387
# File 'app/models/topic.rb', line 1382

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



134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
# File 'app/models/topic.rb', line 134

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



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

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



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

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



1834
1835
1836
1837
# File 'app/models/topic.rb', line 1834

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

#update_meta_data(data) ⇒ Object



632
633
634
635
# File 'app/models/topic.rb', line 632

def (data)
  custom_fields.update(data)
  save
end

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



1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
# File 'app/models/topic.rb', line 1431

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.



1276
1277
1278
1279
1280
# File 'app/models/topic.rb', line 1276

def update_statistics
  feature_topic_users
  update_action_counts
  Topic.reset_highest(id)
end

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



782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
# File 'app/models/topic.rb', line 782

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



1401
1402
1403
# File 'app/models/topic.rb', line 1401

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

#visible_tags(guardian) ⇒ Object



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

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