# frozen_string_literal: true require_dependency 'distributed_cache' class Category < ActiveRecord::Base self.ignored_columns = %w{ uploaded_meta_id } include Searchable include Positionable include HasCustomFields include CategoryHashtag include AnonCacheInvalidator include HasDestroyedWebHook REQUIRE_TOPIC_APPROVAL = 'require_topic_approval' REQUIRE_REPLY_APPROVAL = 'require_reply_approval' NUM_AUTO_BUMP_DAILY = 'num_auto_bump_daily' register_custom_field_type(REQUIRE_TOPIC_APPROVAL, :boolean) register_custom_field_type(REQUIRE_REPLY_APPROVAL, :boolean) register_custom_field_type(NUM_AUTO_BUMP_DAILY, :integer) belongs_to :topic, dependent: :destroy belongs_to :topic_only_relative_url, -> { select "id, title, slug" }, class_name: "Topic", foreign_key: "topic_id" belongs_to :user belongs_to :latest_post, class_name: "Post" belongs_to :uploaded_logo, class_name: "Upload" belongs_to :uploaded_background, class_name: "Upload" has_many :topics has_many :category_users has_many :category_featured_topics has_many :featured_topics, through: :category_featured_topics, source: :topic has_many :category_groups, dependent: :destroy has_many :groups, through: :category_groups has_and_belongs_to_many :web_hooks validates :user_id, presence: true validates :name, if: Proc.new { |c| c.new_record? || c.will_save_change_to_name? }, presence: true, uniqueness: { scope: :parent_category_id, case_sensitive: false }, length: { in: 1..50 } validates :num_featured_topics, numericality: { only_integer: true, greater_than: 0 } validates :search_priority, inclusion: { in: Searchable::PRIORITIES.values } validate :parent_category_validator validate :email_in_validator validate :ensure_slug validate :permissions_compatibility_validator validates :auto_close_hours, numericality: { greater_than: 0, less_than_or_equal_to: 87600 }, allow_nil: true after_create :create_category_definition before_save :apply_permissions before_save :downcase_email before_save :downcase_name after_save :publish_discourse_stylesheet after_save :publish_category after_save :reset_topic_ids_cache after_save :clear_url_cache after_save :index_search after_destroy :reset_topic_ids_cache after_destroy :publish_category_deletion after_destroy :remove_site_settings after_create :delete_category_permalink after_update :rename_category_definition, if: :saved_change_to_name? after_update :create_category_permalink, if: :saved_change_to_slug? after_commit :trigger_category_created_event, on: :create after_commit :trigger_category_updated_event, on: :update after_commit :trigger_category_destroyed_event, on: :destroy belongs_to :parent_category, class_name: 'Category' has_many :subcategories, class_name: 'Category', foreign_key: 'parent_category_id' has_many :category_tags, dependent: :destroy has_many :tags, through: :category_tags has_many :category_tag_groups, dependent: :destroy has_many :tag_groups, through: :category_tag_groups scope :latest, -> { order('topic_count DESC') } scope :secured, -> (guardian = nil) { ids = guardian.secure_category_ids if guardian if ids.present? where("NOT categories.read_restricted OR categories.id IN (:cats)", cats: ids).references(:categories) else where("NOT categories.read_restricted").references(:categories) end } TOPIC_CREATION_PERMISSIONS ||= [:full] POST_CREATION_PERMISSIONS ||= [:create_post, :full] scope :topic_create_allowed, -> (guardian) { scoped_to_permissions(guardian, TOPIC_CREATION_PERMISSIONS) } scope :post_create_allowed, -> (guardian) { scoped_to_permissions(guardian, POST_CREATION_PERMISSIONS) } delegate :post_template, to: 'self.class' # permission is just used by serialization # we may consider wrapping this in another spot attr_accessor :displayable_topics, :permission, :subcategory_ids, :notification_level, :has_children # Allows us to skip creating the category definition topic in tests. attr_accessor :skip_category_definition @topic_id_cache = DistributedCache.new('category_topic_ids') def self.topic_ids @topic_id_cache['ids'] || reset_topic_ids_cache end def self.reset_topic_ids_cache @topic_id_cache['ids'] = Set.new(Category.pluck(:topic_id).compact) end def reset_topic_ids_cache Category.reset_topic_ids_cache end def self.scoped_to_permissions(guardian, permission_types) if guardian.try(:is_admin?) all elsif !guardian || guardian.anonymous? if permission_types.include?(:readonly) where("NOT categories.read_restricted") else where("1 = 0") end else permissions = permission_types.map { |p| CategoryGroup.permission_types[p] } where("(:staged AND LENGTH(COALESCE(email_in, '')) > 0 AND email_in_allow_strangers) OR categories.id NOT IN (SELECT category_id FROM category_groups) OR categories.id IN ( SELECT category_id FROM category_groups WHERE permission_type IN (:permissions) AND (group_id = :everyone OR group_id IN (SELECT group_id FROM group_users WHERE user_id = :user_id)) )", staged: guardian.is_staged?, permissions: permissions, user_id: guardian.user.id, everyone: Group[:everyone].id) end end def self.update_stats topics_with_post_count = Topic .select("topics.category_id, COUNT(*) topic_count, SUM(topics.posts_count) post_count") .where("topics.id NOT IN (select cc.topic_id from categories cc WHERE topic_id IS NOT NULL)") .group("topics.category_id") .visible.to_sql DB.exec <<~SQL UPDATE categories c SET topic_count = x.topic_count, post_count = x.post_count FROM (#{topics_with_post_count}) x WHERE x.category_id = c.id AND (c.topic_count <> x.topic_count OR c.post_count <> x.post_count) SQL # Yes, there are a lot of queries happening below. # Performing a lot of queries is actually faster than using one big update # statement with sub-selects on large databases with many categories, # topics, and posts. # # The old method with the one query is here: # https://github.com/discourse/discourse/blob/5f34a621b5416a53a2e79a145e927fca7d5471e8/app/models/category.rb # # If you refactor this, test performance on a large database. Category.all.each do |c| topics = c.topics.visible topics = topics.where(['topics.id <> ?', c.topic_id]) if c.topic_id c.topics_year = topics.created_since(1.year.ago).count c.topics_month = topics.created_since(1.month.ago).count c.topics_week = topics.created_since(1.week.ago).count c.topics_day = topics.created_since(1.day.ago).count posts = c.visible_posts c.posts_year = posts.created_since(1.year.ago).count c.posts_month = posts.created_since(1.month.ago).count c.posts_week = posts.created_since(1.week.ago).count c.posts_day = posts.created_since(1.day.ago).count c.save if c.changed? end end def visible_posts query = Post.joins(:topic) .where(['topics.category_id = ?', self.id]) .where('topics.visible = true') .where('posts.deleted_at IS NULL') .where('posts.user_deleted = false') self.topic_id ? query.where(['topics.id <> ?', self.topic_id]) : query end # Internal: Generate the text of post prompting to enter category description. def self.post_template I18n.t("category.post_template", replace_paragraph: I18n.t("category.replace_paragraph")) end def create_category_definition return if skip_category_definition t = Topic.new(title: I18n.t("category.topic_prefix", category: name), user: user, pinned_at: Time.now, category_id: id) t.skip_callbacks = true t.ignore_category_auto_close = true t.delete_topic_timer(TopicTimer.types[:close]) t.save!(validate: false) update_column(:topic_id, t.id) t.posts.create(raw: description || post_template, user: user) end def topic_url if has_attribute?("topic_slug") Topic.relative_url(topic_id, read_attribute(:topic_slug)) else topic_only_relative_url.try(:relative_url) end end def description_text return nil unless self.description @@cache ||= LruRedux::ThreadSafeCache.new(1000) @@cache.getset(self.description) do Nokogiri::HTML.fragment(self.description).text.strip.html_safe end end def duplicate_slug? Category.where(slug: self.slug, parent_category_id: parent_category_id).where.not(id: id).any? end def ensure_slug return unless name.present? self.name.strip! if slug.present? # santized custom slug self.slug = Slug.sanitize(slug) errors.add(:slug, 'is already in use') if duplicate_slug? else # auto slug self.slug = Slug.for(name, '') self.slug = '' if duplicate_slug? end # only allow to use category itself id. new_record doesn't have a id. unless new_record? match_id = /^(\d+)-category/.match(self.slug) errors.add(:slug, :invalid) if match_id && match_id[1] && match_id[1] != self.id.to_s end end def slug_for_url slug.present? ? self.slug : "#{self.id}-category" end def publish_category group_ids = self.groups.pluck(:id) if self.read_restricted MessageBus.publish('/categories', { categories: ActiveModel::ArraySerializer.new([self]).as_json }, group_ids: group_ids) end def remove_site_settings SiteSetting.all_settings.each do |s| if s[:type] == 'category' && s[:value].to_i == self.id SiteSetting.send("#{s[:setting]}=", '') end end end def publish_category_deletion MessageBus.publish('/categories', deleted_categories: [self.id]) end def parent_category_validator if parent_category_id errors.add(:base, I18n.t("category.errors.self_parent")) if parent_category_id == id errors.add(:base, I18n.t("category.errors.uncategorized_parent")) if uncategorized? grandfather_id = Category.where(id: parent_category_id).pluck(:parent_category_id).first errors.add(:base, I18n.t("category.errors.depth")) if grandfather_id end end def group_names=(names) # this line bothers me, destroying in AR can not seem to be queued, thinking of extending it category_groups.destroy_all unless new_record? ids = Group.where(name: names.split(",")).pluck(:id) ids.each do |id| category_groups.build(group_id: id) end end # will reset permission on a topic to a particular # set. # # Available permissions are, :full, :create_post, :readonly # hash can be: # # :everyone => :full - everyone has everything # :everyone => :readonly, :staff => :full # 7 => 1 # you can pass a group_id and permission id def set_permissions(permissions) self.read_restricted, @permissions = Category.resolve_permissions(permissions) # Ideally we can just call .clear here, but it runs SQL, we only want to run it # on save. end def permissions=(permissions) set_permissions(permissions) end def permissions_params hash = {} category_groups.includes(:group).each do |category_group| if category_group.group.present? hash[category_group.group_name] = category_group.permission_type end end hash end def apply_permissions if @permissions category_groups.destroy_all @permissions.each do |group_id, permission_type| category_groups.build(group_id: group_id, permission_type: permission_type) end @permissions = nil end end def self.resolve_permissions(permissions) read_restricted = true everyone = Group::AUTO_GROUPS[:everyone] full = CategoryGroup.permission_types[:full] mapped = permissions.map do |group, permission| group_id = Group.group_id_from_param(group) permission = CategoryGroup.permission_types[permission] unless permission.is_a?(Integer) [group_id, permission] end mapped.each do |group, permission| if group == everyone && permission == full return [false, []] end read_restricted = false if group == everyone end [read_restricted, mapped] end def require_topic_approval? custom_fields[REQUIRE_TOPIC_APPROVAL] end def require_reply_approval? custom_fields[REQUIRE_REPLY_APPROVAL] end def num_auto_bump_daily custom_fields[NUM_AUTO_BUMP_DAILY] end def num_auto_bump_daily=(v) custom_fields[NUM_AUTO_BUMP_DAILY] = v end def auto_bump_limiter return nil if num_auto_bump_daily.to_i == 0 RateLimiter.new(nil, "auto_bump_limit_#{self.id}", 1, 86400 / num_auto_bump_daily.to_i) end def clear_auto_bump_cache! auto_bump_limiter&.clear! end def self.auto_bump_topic! bumped = false auto_bumps = CategoryCustomField .where(name: Category::NUM_AUTO_BUMP_DAILY) .where('NULLIF(value, \'\')::int > 0') .pluck(:category_id) if (auto_bumps.length > 0) auto_bumps.shuffle.each do |category_id| bumped = Category.find_by(id: category_id)&.auto_bump_topic! break if bumped end end bumped end # will automatically bump a single topic # if number of automatically bumped topics is smaller than threshold def auto_bump_topic! return false if num_auto_bump_daily.to_i == 0 limiter = auto_bump_limiter return false if !limiter.can_perform? filters = [] DiscourseEvent.trigger(:filter_auto_bump_topics, self, filters) relation = Topic if filters.length > 0 filters.each do |filter| relation = filter.call(relation) end end topic = relation .visible .listable_topics .where(category_id: self.id) .where('id <> ?', self.topic_id) .where('bumped_at < ?', 1.day.ago) .where('pinned_at IS NULL AND NOT closed AND NOT archived') .order('bumped_at ASC') .limit(1) .first if topic topic.add_small_action(Discourse.system_user, "autobumped", nil, bump: true) limiter.performed! true else false end end def allowed_tags=(tag_names_arg) DiscourseTagging.add_or_create_tags_by_name(self, tag_names_arg, unlimited: true) end def allowed_tag_groups=(group_names) self.tag_groups = TagGroup.where(name: group_names).all.to_a end def downcase_email self.email_in = (email_in || "").strip.downcase.presence end def email_in_validator return if self.email_in.blank? email_in.split("|").each do |email| escaped = Rack::Utils.escape_html(email) if !Email.is_valid?(email) self.errors.add(:base, I18n.t('category.errors.invalid_email_in', email: escaped)) elsif group = Group.find_by_email(email) self.errors.add(:base, I18n.t('category.errors.email_already_used_in_group', email: escaped, group_name: Rack::Utils.escape_html(group.name))) elsif category = Category.where.not(id: self.id).find_by_email(email) self.errors.add(:base, I18n.t('category.errors.email_already_used_in_category', email: escaped, category_name: Rack::Utils.escape_html(category.name))) end end end def downcase_name self.name_lower = name.downcase if self.name end def visible_group_names(user) self.groups.visible_groups(user) end def secure_group_ids if self.read_restricted? groups.pluck("groups.id") end end def update_latest latest_post_id = Post .order("posts.created_at desc") .where("NOT hidden") .joins("join topics on topics.id = topic_id") .where("topics.category_id = :id", id: self.id) .limit(1) .pluck("posts.id") .first latest_topic_id = Topic .order("topics.created_at desc") .where("visible") .where("topics.category_id = :id", id: self.id) .limit(1) .pluck("topics.id") .first self.update_attributes(latest_topic_id: latest_topic_id, latest_post_id: latest_post_id) end def self.query_parent_category(parent_slug) self.where(slug: parent_slug, parent_category_id: nil).pluck(:id).first || self.where(id: parent_slug.to_i).pluck(:id).first end def self.query_category(slug_or_id, parent_category_id) self.where(slug: slug_or_id, parent_category_id: parent_category_id).first || self.where(id: slug_or_id.to_i, parent_category_id: parent_category_id).first end def self.find_by_email(email) self.where("string_to_array(email_in, '|') @> ARRAY[?]", Email.downcase(email)).first end def has_children? @has_children ||= (id && Category.where(parent_category_id: id).exists?) ? :true : :false @has_children == :true end def uncategorized? id == SiteSetting.uncategorized_category_id end @@url_cache = DistributedCache.new('category_url') def clear_url_cache @@url_cache.clear end def full_slug(separator = "-") start_idx = "#{Discourse.base_uri}/c/".length url[start_idx..-1].gsub("/", separator) end def url url = @@url_cache[self.id] unless url url = +"#{Discourse.base_uri}/c" url << "/#{parent_category.slug}" if parent_category_id url << "/#{slug}" @@url_cache[self.id] = -url end url end def url_with_id self.parent_category ? "#{url}/#{self.id}" : "#{Discourse.base_uri}/c/#{self.id}-#{self.slug}" end # If the name changes, try and update the category definition topic too if it's # an exact match def rename_category_definition old_name = saved_changes.transform_values(&:first)["name"] return unless topic.present? if topic.title == I18n.t("category.topic_prefix", category: old_name) topic.update_attribute(:title, I18n.t("category.topic_prefix", category: name)) end end def create_category_permalink old_slug = saved_changes.transform_values(&:first)["slug"] url = +"#{Discourse.base_uri}/c" url << "/#{parent_category.slug}" if parent_category_id url << "/#{old_slug}" url = Permalink.normalize_url(url) if Permalink.where(url: url).exists? Permalink.where(url: url).update_all(category_id: id) else Permalink.create(url: url, category_id: id) end end def delete_category_permalink if self.parent_category permalink = Permalink.find_by_url("c/#{self.parent_category.slug}/#{slug}") else permalink = Permalink.find_by_url("c/#{slug}") end permalink.destroy if permalink end def publish_discourse_stylesheet Stylesheet::Manager.cache.clear end def index_search SearchIndexer.index(self) end def self.find_by_slug(category_slug, parent_category_slug = nil) if parent_category_slug parent_category_id = self.where(slug: parent_category_slug, parent_category_id: nil).pluck(:id).first self.where(slug: category_slug, parent_category_id: parent_category_id).first else self.where(slug: category_slug, parent_category_id: nil).first end end def subcategory_list_includes_topics? subcategory_list_style.end_with?("with_featured_topics") end %i{ category_created category_updated category_destroyed }.each do |event| define_method("trigger_#{event}_event") do DiscourseEvent.trigger(event, self) true end end def permissions_compatibility_validator # when saving subcategories if @permissions && parent_category_id.present? return if parent_category.category_groups.empty? parent_permissions = parent_category.category_groups.pluck(:group_id, :permission_type) child_permissions = @permissions.empty? ? [[Group[:everyone].id, CategoryGroup.permission_types[:full]]] : @permissions check_permissions_compatibility(parent_permissions, child_permissions) # when saving parent category elsif @permissions && subcategories.present? return if @permissions.empty? parent_permissions = @permissions child_permissions = subcategories_permissions.uniq check_permissions_compatibility(parent_permissions, child_permissions) end end private def check_permissions_compatibility(parent_permissions, child_permissions) parent_groups = parent_permissions.map(&:first) return if parent_groups.include?(Group[:everyone].id) child_groups = child_permissions.map(&:first) only_subcategory_groups = child_groups - parent_groups if only_subcategory_groups.present? group_names = Group.where(id: only_subcategory_groups).pluck(:name).join(", ") errors.add(:base, I18n.t("category.errors.permission_conflict", group_names: group_names)) end end def subcategories_permissions CategoryGroup.joins(:category) .where(['categories.parent_category_id = ?', self.id]) .pluck(:group_id, :permission_type) .uniq end end # == Schema Information # # Table name: categories # # id :integer not null, primary key # name :string(50) not null # color :string(6) default("0088CC"), not null # topic_id :integer # topic_count :integer default(0), not null # created_at :datetime not null # updated_at :datetime not null # user_id :integer not null # topics_year :integer default(0) # topics_month :integer default(0) # topics_week :integer default(0) # slug :string not null # description :text # text_color :string(6) default("FFFFFF"), not null # read_restricted :boolean default(FALSE), not null # auto_close_hours :float # post_count :integer default(0), not null # latest_post_id :integer # latest_topic_id :integer # position :integer # parent_category_id :integer # posts_year :integer default(0) # posts_month :integer default(0) # posts_week :integer default(0) # email_in :string # email_in_allow_strangers :boolean default(FALSE) # topics_day :integer default(0) # posts_day :integer default(0) # allow_badges :boolean default(TRUE), not null # name_lower :string(50) not null # auto_close_based_on_last_post :boolean default(FALSE) # topic_template :text # contains_messages :boolean # sort_order :string # sort_ascending :boolean # uploaded_logo_id :integer # uploaded_background_id :integer # topic_featured_link_allowed :boolean default(TRUE) # all_topics_wiki :boolean default(FALSE), not null # show_subcategory_list :boolean default(FALSE) # num_featured_topics :integer default(3) # default_view :string(50) # subcategory_list_style :string(50) default("rows_with_featured_topics") # default_top_period :string(20) default("all") # mailinglist_mirror :boolean default(FALSE), not null # suppress_from_latest :boolean default(FALSE) # minimum_required_tags :integer default(0), not null # navigate_to_first_post_after_read :boolean default(FALSE), not null # search_priority :integer default(0) # # Indexes # # index_categories_on_email_in (email_in) UNIQUE # index_categories_on_search_priority (search_priority) # index_categories_on_topic_count (topic_count) # unique_index_categories_on_name (COALESCE(parent_category_id, '-1'::integer), name) UNIQUE #