mirror of
https://github.com/discourse/discourse.git
synced 2024-11-26 06:23:43 +08:00
8ce836c039
When lazy load categories is enabled, categories should be loaded with user activity items and drafts because the categories may not be preloaded on the client side.
1274 lines
40 KiB
Ruby
1274 lines
40 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class Category < ActiveRecord::Base
|
|
RESERVED_SLUGS = ["none"]
|
|
|
|
self.ignored_columns = [
|
|
:suppress_from_latest, # TODO(2020-11-18): remove
|
|
:required_tag_group_id, # TODO(2023-04-01): remove
|
|
:min_tags_from_required_group, # TODO(2023-04-01): remove
|
|
]
|
|
|
|
include Searchable
|
|
include Positionable
|
|
include HasCustomFields
|
|
include CategoryHashtag
|
|
include AnonCacheInvalidator
|
|
include HasDestroyedWebHook
|
|
|
|
SLUG_REF_SEPARATOR = ":"
|
|
|
|
belongs_to :topic
|
|
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_logo_dark, class_name: "Upload"
|
|
belongs_to :uploaded_background, class_name: "Upload"
|
|
belongs_to :uploaded_background_dark, 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_many :topic_timers, dependent: :destroy
|
|
has_many :upload_references, as: :target, dependent: :destroy
|
|
|
|
has_one :category_setting, dependent: :destroy
|
|
|
|
delegate :auto_bump_cooldown_days,
|
|
:num_auto_bump_daily,
|
|
:num_auto_bump_daily=,
|
|
:require_reply_approval,
|
|
:require_reply_approval=,
|
|
:require_reply_approval?,
|
|
:require_topic_approval,
|
|
:require_topic_approval=,
|
|
:require_topic_approval?,
|
|
to: :category_setting,
|
|
allow_nil: true
|
|
|
|
has_and_belongs_to_many :web_hooks
|
|
|
|
accepts_nested_attributes_for :category_setting, update_only: true
|
|
|
|
validates :user_id, presence: true
|
|
|
|
validates :name,
|
|
if:
|
|
Proc.new { |c|
|
|
c.new_record? || c.will_save_change_to_name? ||
|
|
c.will_save_change_to_parent_category_id?
|
|
},
|
|
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 :default_slow_mode_seconds,
|
|
numericality: {
|
|
only_integer: true,
|
|
greater_than: 0,
|
|
},
|
|
allow_nil: true
|
|
validates :auto_close_hours,
|
|
numericality: {
|
|
greater_than: 0,
|
|
less_than_or_equal_to: 87_600,
|
|
},
|
|
allow_nil: true
|
|
validates :slug, exclusion: { in: RESERVED_SLUGS }
|
|
|
|
after_create :create_category_definition
|
|
after_destroy :trash_category_definition
|
|
after_destroy :clear_related_site_settings
|
|
|
|
before_save :apply_permissions
|
|
before_save :downcase_email
|
|
before_save :downcase_name
|
|
before_save :ensure_category_setting
|
|
|
|
after_save :reset_topic_ids_cache
|
|
after_save :clear_subcategory_ids
|
|
after_save :clear_url_cache
|
|
after_save :update_reviewables
|
|
after_save :publish_discourse_stylesheet
|
|
after_save :publish_category
|
|
|
|
after_save do
|
|
if saved_change_to_uploaded_logo_id? || saved_change_to_uploaded_logo_dark_id? ||
|
|
saved_change_to_uploaded_background_id? || saved_change_to_uploaded_background_dark_id?
|
|
upload_ids = [
|
|
self.uploaded_logo_id,
|
|
self.uploaded_logo_dark_id,
|
|
self.uploaded_background_id,
|
|
self.uploaded_background_dark_id,
|
|
]
|
|
UploadReference.ensure_exist!(upload_ids: upload_ids, target: self)
|
|
end
|
|
end
|
|
|
|
after_destroy :reset_topic_ids_cache
|
|
after_destroy :clear_subcategory_ids
|
|
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
|
|
after_commit :clear_site_cache
|
|
|
|
after_save_commit :index_search
|
|
|
|
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 :none_synonym_tags,
|
|
-> { where(target_tag_id: nil) },
|
|
through: :category_tags,
|
|
source: :tag
|
|
has_many :category_tag_groups, dependent: :destroy
|
|
has_many :tag_groups, through: :category_tag_groups
|
|
|
|
has_many :category_required_tag_groups, -> { order(order: :asc) }, dependent: :destroy
|
|
has_many :sidebar_section_links, as: :linkable, dependent: :delete_all
|
|
has_many :embeddable_hosts, dependent: :destroy
|
|
|
|
has_many :category_form_templates, dependent: :destroy
|
|
has_many :form_templates, through: :category_form_templates
|
|
|
|
belongs_to :reviewable_by_group, class_name: "Group"
|
|
|
|
scope :latest, -> { order("topic_count DESC") }
|
|
|
|
scope :secured,
|
|
->(guardian = nil) do
|
|
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
|
|
end
|
|
|
|
TOPIC_CREATION_PERMISSIONS ||= [:full]
|
|
POST_CREATION_PERMISSIONS ||= %i[create_post full]
|
|
|
|
scope :topic_create_allowed,
|
|
->(guardian) do
|
|
scoped = scoped_to_permissions(guardian, TOPIC_CREATION_PERMISSIONS)
|
|
|
|
if !SiteSetting.allow_uncategorized_topics && !guardian.is_staff?
|
|
scoped = scoped.where.not(id: SiteSetting.uncategorized_category_id)
|
|
end
|
|
|
|
scoped
|
|
end
|
|
|
|
scope :post_create_allowed,
|
|
->(guardian) { scoped_to_permissions(guardian, POST_CREATION_PERMISSIONS) }
|
|
|
|
scope :with_ancestors, ->(id) { where(<<~SQL, id) }
|
|
id IN (
|
|
WITH RECURSIVE ancestors(category_id) AS (
|
|
SELECT ?
|
|
UNION
|
|
SELECT parent_category_id
|
|
FROM categories, ancestors
|
|
WHERE id = ancestors.category_id
|
|
)
|
|
SELECT category_id FROM ancestors
|
|
)
|
|
SQL
|
|
|
|
scope :with_parents, ->(ids) { where(<<~SQL, ids: ids) }
|
|
id IN (:ids)
|
|
OR
|
|
id IN (SELECT DISTINCT parent_category_id FROM categories WHERE id IN (:ids))
|
|
SQL
|
|
|
|
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,
|
|
:subcategory_list,
|
|
:notification_level,
|
|
:has_children
|
|
|
|
# Allows us to skip creating the category definition topic in tests.
|
|
attr_accessor :skip_category_definition
|
|
|
|
def self.preload_user_fields!(guardian, categories)
|
|
category_ids = categories.map(&:id)
|
|
|
|
# Load notification levels
|
|
notification_levels = CategoryUser.notification_levels_for(guardian.user)
|
|
notification_levels.default = CategoryUser.default_notification_level
|
|
|
|
# Load permissions
|
|
allowed_topic_create_ids =
|
|
if !guardian.is_admin? && !guardian.is_anonymous?
|
|
Category.topic_create_allowed(guardian).where(id: category_ids).pluck(:id).to_set
|
|
end
|
|
|
|
# Categories with children
|
|
with_children =
|
|
Category
|
|
.secured(guardian)
|
|
.where(parent_category_id: category_ids)
|
|
.pluck(:parent_category_id)
|
|
.to_set
|
|
|
|
# Update category attributes
|
|
categories.each do |category|
|
|
category.notification_level = notification_levels[category[:id]]
|
|
|
|
category.permission = CategoryGroup.permission_types[:full] if guardian.is_admin? ||
|
|
allowed_topic_create_ids&.include?(category[:id])
|
|
|
|
category.has_children = with_children.include?(category[:id])
|
|
end
|
|
end
|
|
|
|
def self.ancestors_of(category_ids)
|
|
ancestor_ids = []
|
|
|
|
SiteSetting.max_category_nesting.times do
|
|
category_ids =
|
|
where(id: category_ids)
|
|
.where.not(parent_category_id: nil)
|
|
.pluck("DISTINCT parent_category_id")
|
|
|
|
ancestor_ids.concat(category_ids)
|
|
|
|
break if category_ids.empty?
|
|
end
|
|
|
|
where(id: ancestor_ids)
|
|
end
|
|
|
|
def self.topic_id_cache
|
|
@topic_id_cache ||= DistributedCache.new("category_topic_ids")
|
|
end
|
|
|
|
def self.topic_ids
|
|
topic_id_cache.defer_get_set("ids") { Set.new(Category.pluck(:topic_id).compact) }
|
|
end
|
|
|
|
def self.reset_topic_ids_cache
|
|
topic_id_cache.clear
|
|
end
|
|
|
|
def reset_topic_ids_cache
|
|
Category.reset_topic_ids_cache
|
|
end
|
|
|
|
# Accepts an array of slugs with each item in the array
|
|
# Returns the category ids of the last slug in the array. The slugs array has to follow the proper category
|
|
# nesting hierarchy. If any of the slug in the array is invalid or if the slugs array does not follow the proper
|
|
# category nesting hierarchy, nil is returned.
|
|
#
|
|
# When only a single slug is provided, the category id of all the categories with that slug is returned.
|
|
def self.ids_from_slugs(slugs)
|
|
return [] if slugs.blank?
|
|
|
|
params = {}
|
|
params_index = 0
|
|
|
|
sqls =
|
|
slugs.map do |slug|
|
|
category_slugs =
|
|
slug.split(":").first(SiteSetting.max_category_nesting).map { Slug.for(_1, "") }
|
|
|
|
sql = ""
|
|
|
|
if category_slugs.length == 1
|
|
params[:"slug_#{params_index}"] = category_slugs.first
|
|
sql = "SELECT id FROM categories WHERE slug = :slug_#{params_index}"
|
|
params_index += 1
|
|
else
|
|
category_slugs.each_with_index do |category_slug, index|
|
|
params[:"slug_#{params_index}"] = category_slug
|
|
|
|
sql =
|
|
if index == 0
|
|
"SELECT id FROM categories WHERE slug = :slug_#{params_index} AND parent_category_id IS NULL"
|
|
else
|
|
"SELECT id FROM categories WHERE parent_category_id = (#{sql}) AND slug = :slug_#{params_index}"
|
|
end
|
|
|
|
params_index += 1
|
|
end
|
|
end
|
|
|
|
sql
|
|
end
|
|
|
|
DB.query_single(sqls.join("\nUNION ALL\n"), params)
|
|
end
|
|
|
|
@@subcategory_ids = DistributedCache.new("subcategory_ids")
|
|
|
|
def self.subcategory_ids(category_id)
|
|
@@subcategory_ids.defer_get_set(category_id.to_s) do
|
|
sql = <<~SQL
|
|
WITH RECURSIVE subcategories AS (
|
|
SELECT :category_id id, 1 depth
|
|
UNION
|
|
SELECT categories.id, (subcategories.depth + 1) depth
|
|
FROM categories
|
|
JOIN subcategories ON subcategories.id = categories.parent_category_id
|
|
WHERE subcategories.depth < :max_category_nesting
|
|
)
|
|
SELECT id FROM subcategories
|
|
SQL
|
|
DB.query_single(
|
|
sql,
|
|
category_id: category_id,
|
|
max_category_nesting: SiteSetting.max_category_nesting,
|
|
)
|
|
end
|
|
end
|
|
|
|
def self.clear_subcategory_ids
|
|
@@subcategory_ids.clear
|
|
end
|
|
|
|
def clear_subcategory_ids
|
|
Category.clear_subcategory_ids
|
|
end
|
|
|
|
def top_level?
|
|
self.parent_category_id.nil?
|
|
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::AUTO_GROUPS[:everyone],
|
|
)
|
|
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 = COALESCE(x.topic_count, 0),
|
|
post_count = COALESCE(x.post_count, 0)
|
|
FROM (
|
|
SELECT ccc.id as category_id, stats.topic_count, stats.post_count
|
|
FROM categories ccc
|
|
LEFT JOIN (#{topics_with_post_count}) stats
|
|
ON stats.category_id = ccc.id
|
|
) x
|
|
WHERE x.category_id = c.id
|
|
AND (c.topic_count <> COALESCE(x.topic_count, 0) OR c.post_count <> COALESCE(x.post_count, 0))
|
|
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
|
|
|
|
Topic.transaction do
|
|
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)
|
|
post = t.posts.build(raw: description || post_template, user: user)
|
|
post.save!(validate: false)
|
|
update_column(:description, post.cooked) if description.present?
|
|
|
|
t
|
|
end
|
|
end
|
|
|
|
def trash_category_definition
|
|
self.topic&.trash!
|
|
end
|
|
|
|
def clear_related_site_settings
|
|
SiteSetting.general_category_id = -1 if self.id == SiteSetting.general_category_id
|
|
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_text ||= LruRedux::ThreadSafeCache.new(1000)
|
|
@@cache_text.getset(self.description) do
|
|
text = Nokogiri::HTML5.fragment(self.description).text.strip
|
|
ERB::Util.html_escape(text).html_safe
|
|
end
|
|
end
|
|
|
|
def description_excerpt
|
|
return nil unless self.description
|
|
|
|
@@cache_excerpt ||= LruRedux::ThreadSafeCache.new(1000)
|
|
@@cache_excerpt.getset(self.description) { PrettyText.excerpt(description, 300) }
|
|
end
|
|
|
|
def access_category_via_group
|
|
Group
|
|
.joins(:category_groups)
|
|
.where("category_groups.category_id = ?", self.id)
|
|
.where("groups.public_admission OR groups.allow_membership_requests")
|
|
.order(:allow_membership_requests)
|
|
.first
|
|
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?
|
|
# if we don't unescape it first we strip the % from the encoded version
|
|
slug = SiteSetting.slug_generation_method == "encoded" ? CGI.unescape(self.slug) : self.slug
|
|
self.slug = Slug.for(slug, "", method: :encoded)
|
|
|
|
if self.slug.blank?
|
|
errors.add(:slug, :invalid)
|
|
elsif SiteSetting.slug_generation_method == "ascii" && !CGI.unescape(self.slug).ascii_only?
|
|
errors.add(:slug, I18n.t("category.errors.slug_contains_non_ascii_chars"))
|
|
elsif duplicate_slug?
|
|
errors.add(:slug, I18n.t("category.errors.is_already_in_use"))
|
|
end
|
|
else
|
|
# auto slug
|
|
self.slug = Slug.for(name, "")
|
|
self.slug = "" if duplicate_slug?
|
|
end
|
|
|
|
# only allow to use category itself id.
|
|
match_id = /\A(\d+)-category/.match(self.slug)
|
|
if match_id.present?
|
|
errors.add(:slug, :invalid) if new_record? || (match_id[1] != self.id.to_s)
|
|
end
|
|
end
|
|
|
|
def slug_for_url
|
|
slug.present? ? self.slug : "#{self.id}-category"
|
|
end
|
|
|
|
def publish_category
|
|
if self.read_restricted
|
|
group_ids = self.groups.pluck(:id)
|
|
|
|
if group_ids.present?
|
|
MessageBus.publish(
|
|
"/categories",
|
|
{ categories: ActiveModel::ArraySerializer.new([self]).as_json },
|
|
group_ids: group_ids,
|
|
)
|
|
end
|
|
else
|
|
MessageBus.publish(
|
|
"/categories",
|
|
{ categories: ActiveModel::ArraySerializer.new([self]).as_json },
|
|
)
|
|
end
|
|
end
|
|
|
|
def remove_site_settings
|
|
SiteSetting.all_settings.each do |s|
|
|
SiteSetting.set(s[:setting], "") if s[:type] == "category" && s[:value].to_i == self.id
|
|
end
|
|
end
|
|
|
|
def publish_category_deletion
|
|
MessageBus.publish("/categories", deleted_categories: [self.id])
|
|
end
|
|
|
|
# This is used in a validation so has to produce accurate results before the
|
|
# record has been saved
|
|
def height_of_ancestors(max_height = SiteSetting.max_category_nesting)
|
|
parent_id = self.parent_category_id
|
|
|
|
return max_height if parent_id == id
|
|
|
|
DB.query(<<~SQL, id: id, parent_id: parent_id, max_height: max_height)[0].max
|
|
WITH RECURSIVE ancestors(parent_category_id, height) AS (
|
|
SELECT :parent_id :: integer, 0
|
|
|
|
UNION ALL
|
|
|
|
SELECT
|
|
categories.parent_category_id,
|
|
CASE
|
|
WHEN categories.parent_category_id = :id THEN :max_height
|
|
ELSE ancestors.height + 1
|
|
END
|
|
FROM categories, ancestors
|
|
WHERE categories.id = ancestors.parent_category_id
|
|
AND ancestors.height < :max_height
|
|
)
|
|
|
|
SELECT max(height) FROM ancestors
|
|
SQL
|
|
end
|
|
|
|
# This is used in a validation so has to produce accurate results before the
|
|
# record has been saved
|
|
def depth_of_descendants(max_depth = SiteSetting.max_category_nesting)
|
|
parent_id = self.parent_category_id
|
|
|
|
return max_depth if parent_id == id
|
|
|
|
DB.query(<<~SQL, id: id, parent_id: parent_id, max_depth: max_depth)[0].max
|
|
WITH RECURSIVE descendants(id, depth) AS (
|
|
SELECT :id :: integer, 0
|
|
|
|
UNION ALL
|
|
|
|
SELECT
|
|
categories.id,
|
|
CASE
|
|
WHEN categories.id = :parent_id THEN :max_depth
|
|
ELSE descendants.depth + 1
|
|
END
|
|
FROM categories, descendants
|
|
WHERE categories.parent_category_id = descendants.id
|
|
AND descendants.depth < :max_depth
|
|
)
|
|
|
|
SELECT max(depth) FROM descendants
|
|
SQL
|
|
end
|
|
|
|
def parent_category_validator
|
|
if parent_category_id
|
|
errors.add(:base, I18n.t("category.errors.uncategorized_parent")) if uncategorized?
|
|
|
|
errors.add(:base, I18n.t("category.errors.self_parent")) if parent_category_id == id
|
|
|
|
total_depth = height_of_ancestors + 1 + depth_of_descendants
|
|
if total_depth > SiteSetting.max_category_nesting
|
|
errors.add(:base, I18n.t("category.errors.depth"))
|
|
end
|
|
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 { |id| category_groups.build(group_id: id) }
|
|
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|
|
|
return false, [] if group == everyone && permission == full
|
|
|
|
read_restricted = false if group == everyone
|
|
end
|
|
|
|
[read_restricted, mapped]
|
|
end
|
|
|
|
def auto_bump_limiter
|
|
return nil if num_auto_bump_daily.to_i == 0
|
|
RateLimiter.new(nil, "auto_bump_limit_#{self.id}", 1, 86_400 / num_auto_bump_daily.to_i)
|
|
end
|
|
|
|
def clear_auto_bump_cache!
|
|
auto_bump_limiter&.clear!
|
|
end
|
|
|
|
def self.auto_bump_topic!
|
|
Category
|
|
.joins(:category_setting)
|
|
.where("category_settings.num_auto_bump_daily > 0")
|
|
.shuffle
|
|
.any?(&:auto_bump_topic!)
|
|
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
|
|
|
|
filters.each { |filter| relation = filter.call(relation) } if filters.length > 0
|
|
|
|
topic =
|
|
relation
|
|
.visible
|
|
.listable_topics
|
|
.exclude_scheduled_bump_topics
|
|
.where(category_id: self.id)
|
|
.where("id <> ?", self.topic_id)
|
|
.where("bumped_at < ?", (self.auto_bump_cooldown_days || 1).days.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 required_tag_groups=(required_groups)
|
|
map =
|
|
Array(required_groups)
|
|
.map
|
|
.with_index { |rg, i| [rg["name"], { min_count: rg["min_count"].to_i, order: i }] }
|
|
.to_h
|
|
tag_groups = TagGroup.where(name: map.keys)
|
|
|
|
self.category_required_tag_groups =
|
|
tag_groups
|
|
.map do |tag_group|
|
|
attrs = map[tag_group.name]
|
|
CategoryRequiredTagGroup.new(tag_group: tag_group, **attrs)
|
|
end
|
|
.sort_by(&:order)
|
|
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
|
|
groups.pluck("groups.id") if self.read_restricted?
|
|
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(latest_topic_id: latest_topic_id, latest_post_id: latest_post_id)
|
|
end
|
|
|
|
def self.query_parent_category(parent_slug)
|
|
encoded_parent_slug = CGI.escape(parent_slug) if SiteSetting.slug_generation_method == "encoded"
|
|
self.where(slug: (encoded_parent_slug || parent_slug), parent_category_id: nil).pick(:id) ||
|
|
self.where(id: parent_slug.to_i).pick(:id)
|
|
end
|
|
|
|
def self.query_category(slug_or_id, parent_category_id)
|
|
encoded_slug_or_id = CGI.escape(slug_or_id) if SiteSetting.slug_generation_method == "encoded"
|
|
self.where(
|
|
slug: (encoded_slug_or_id || 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?
|
|
return @has_children if defined?(@has_children)
|
|
@has_children = (id && Category.where(parent_category_id: id).exists?)
|
|
end
|
|
|
|
def uncategorized?
|
|
id == SiteSetting.uncategorized_category_id
|
|
end
|
|
|
|
def seeded?
|
|
[
|
|
SiteSetting.general_category_id,
|
|
SiteSetting.meta_category_id,
|
|
SiteSetting.staff_category_id,
|
|
SiteSetting.uncategorized_category_id,
|
|
].include? id
|
|
end
|
|
|
|
def full_slug(separator = "-")
|
|
start_idx = "#{Discourse.base_path}/c/".size
|
|
url[start_idx..-1].gsub("/", separator)
|
|
end
|
|
|
|
@@url_cache = DistributedCache.new("category_url")
|
|
|
|
def clear_url_cache
|
|
@@url_cache.clear
|
|
end
|
|
|
|
def url
|
|
@@url_cache.defer_get_set(self.id.to_s) do
|
|
"#{Discourse.base_path}/c/#{slug_path.join("/")}/#{self.id}"
|
|
end
|
|
end
|
|
|
|
# If the name changes, try and update the category definition topic too if it's an exact match
|
|
def rename_category_definition
|
|
return unless topic.present?
|
|
old_name = saved_changes.transform_values(&:first)["name"]
|
|
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_path}/c"
|
|
url << "/#{parent_category.slug_path.join("/")}" if parent_category_id
|
|
url << "/#{old_slug}/#{id}"
|
|
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
|
|
permalink = Permalink.find_by_url("c/#{slug_path.join("/")}")
|
|
permalink.destroy if permalink
|
|
end
|
|
|
|
def publish_discourse_stylesheet
|
|
Stylesheet::Manager.cache.clear
|
|
end
|
|
|
|
def index_search
|
|
Jobs.enqueue(
|
|
:index_category_for_search,
|
|
category_id: self.id,
|
|
force: saved_change_to_attribute?(:name),
|
|
)
|
|
end
|
|
|
|
def update_reviewables
|
|
if should_update_reviewables?
|
|
Reviewable.where(category_id: id).update_all(reviewable_by_group_id: reviewable_by_group_id)
|
|
end
|
|
end
|
|
|
|
def self.find_by_slug_path(slug_path)
|
|
return nil if slug_path.empty?
|
|
return nil if slug_path.size > SiteSetting.max_category_nesting
|
|
|
|
slug_path.map! { |slug| CGI.escape(slug.downcase) }
|
|
|
|
query =
|
|
slug_path.inject(nil) do |parent_id, slug|
|
|
category = Category.where(slug: slug, parent_category_id: parent_id)
|
|
|
|
if match_id = /\A(\d+)-category/.match(slug).presence
|
|
category = category.or(Category.where(id: match_id[1], parent_category_id: parent_id))
|
|
end
|
|
|
|
category.select(:id)
|
|
end
|
|
|
|
Category.find_by_id(query)
|
|
end
|
|
|
|
def self.find_by_slug_path_with_id(slug_path_with_id)
|
|
slug_path = slug_path_with_id.split("/")
|
|
|
|
if slug_path.last =~ /\A\d+\Z/
|
|
id = slug_path.pop.to_i
|
|
Category.find_by_id(id)
|
|
else
|
|
Category.find_by_slug_path(slug_path)
|
|
end
|
|
end
|
|
|
|
def self.find_by_slug(category_slug, parent_category_slug = nil)
|
|
return nil if category_slug.nil?
|
|
|
|
find_by_slug_path([parent_category_slug, category_slug].compact)
|
|
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 =
|
|
(
|
|
if @permissions.empty?
|
|
[[Group[:everyone].id, CategoryGroup.permission_types[:full]]]
|
|
else
|
|
@permissions
|
|
end
|
|
)
|
|
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
|
|
|
|
def self.ensure_consistency!
|
|
sql = <<~SQL
|
|
SELECT t.id FROM topics t
|
|
JOIN categories c ON c.topic_id = t.id
|
|
LEFT JOIN posts p ON p.topic_id = t.id AND p.post_number = 1
|
|
WHERE p.id IS NULL
|
|
SQL
|
|
|
|
DB.query_single(sql).each { |id| Topic.with_deleted.find_by(id: id).destroy! }
|
|
|
|
sql = <<~SQL
|
|
UPDATE categories c
|
|
SET topic_id = NULL
|
|
WHERE c.id IN (
|
|
SELECT c2.id FROM categories c2
|
|
LEFT JOIN topics t ON t.id = c2.topic_id AND t.deleted_at IS NULL
|
|
WHERE t.id IS NULL AND c2.topic_id IS NOT NULL
|
|
)
|
|
SQL
|
|
|
|
DB.exec(sql)
|
|
|
|
Category
|
|
.joins("LEFT JOIN topics ON categories.topic_id = topics.id AND topics.deleted_at IS NULL")
|
|
.where("categories.id <> ?", SiteSetting.uncategorized_category_id)
|
|
.where(topics: { id: nil })
|
|
.find_each { |category| category.create_category_definition }
|
|
end
|
|
|
|
def slug_path
|
|
if self.parent_category_id.present?
|
|
slug_path = self.parent_category.slug_path
|
|
slug_path.push(self.slug_for_url)
|
|
slug_path
|
|
else
|
|
[self.slug_for_url]
|
|
end
|
|
end
|
|
|
|
def slug_ref(depth: 1)
|
|
if self.parent_category_id.present?
|
|
built_ref = [self.slug]
|
|
parent = self.parent_category
|
|
while parent.present? && (built_ref.length < depth + 1)
|
|
built_ref << parent.slug
|
|
parent = parent.parent_category
|
|
end
|
|
built_ref.reverse.join(Category::SLUG_REF_SEPARATOR)
|
|
else
|
|
self.slug
|
|
end
|
|
end
|
|
|
|
def cannot_delete_reason
|
|
return I18n.t("category.cannot_delete.uncategorized") if self.uncategorized?
|
|
return I18n.t("category.cannot_delete.has_subcategories") if self.has_children?
|
|
|
|
if self.topic_count != 0
|
|
oldest_topic = self.topics.where.not(id: self.topic_id).order("created_at ASC").limit(1).first
|
|
if oldest_topic
|
|
I18n.t(
|
|
"category.cannot_delete.topic_exists",
|
|
count: self.topic_count,
|
|
topic_link: "<a href=\"#{oldest_topic.url}\">#{CGI.escapeHTML(oldest_topic.title)}</a>",
|
|
)
|
|
else
|
|
# This is a weird case, probably indicating a bug.
|
|
I18n.t("category.cannot_delete.topic_exists_no_oldest", count: self.topic_count)
|
|
end
|
|
end
|
|
end
|
|
|
|
def has_restricted_tags?
|
|
tags.count > 0 || tag_groups.count > 0
|
|
end
|
|
|
|
private
|
|
|
|
def ensure_category_setting
|
|
self.build_category_setting if self.category_setting.blank?
|
|
end
|
|
|
|
def should_update_reviewables?
|
|
SiteSetting.enable_category_group_moderation? && saved_change_to_reviewable_by_group_id?
|
|
end
|
|
|
|
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
|
|
everyone = Group[:everyone].id
|
|
full = CategoryGroup.permission_types[:full]
|
|
|
|
result = DB.query(<<-SQL, id: id, everyone: everyone, full: full)
|
|
SELECT category_groups.group_id, category_groups.permission_type
|
|
FROM categories, category_groups
|
|
WHERE categories.parent_category_id = :id
|
|
AND categories.id = category_groups.category_id
|
|
UNION
|
|
SELECT :everyone, :full
|
|
FROM categories
|
|
WHERE categories.parent_category_id = :id
|
|
AND (
|
|
SELECT DISTINCT 1
|
|
FROM category_groups
|
|
WHERE category_groups.category_id = categories.id
|
|
) IS NULL
|
|
SQL
|
|
|
|
result.map { |row| [row.group_id, row.permission_type] }
|
|
end
|
|
|
|
def clear_site_cache
|
|
Site.clear_cache
|
|
end
|
|
|
|
def on_custom_fields_change
|
|
clear_site_cache
|
|
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
|
|
# minimum_required_tags :integer default(0), not null
|
|
# navigate_to_first_post_after_read :boolean default(FALSE), not null
|
|
# search_priority :integer default(0)
|
|
# allow_global_tags :boolean default(FALSE), not null
|
|
# reviewable_by_group_id :integer
|
|
# read_only_banner :string
|
|
# default_list_filter :string(20) default("all")
|
|
# allow_unlimited_owner_edits_on_first_post :boolean default(FALSE), not null
|
|
# default_slow_mode_seconds :integer
|
|
# uploaded_logo_dark_id :integer
|
|
# uploaded_background_dark_id :integer
|
|
#
|
|
# Indexes
|
|
#
|
|
# index_categories_on_email_in (email_in) UNIQUE
|
|
# index_categories_on_reviewable_by_group_id (reviewable_by_group_id)
|
|
# 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
|
|
# unique_index_categories_on_slug (COALESCE(parent_category_id, '-1'::integer), lower((slug)::text)) UNIQUE WHERE ((slug)::text <> ''::text)
|
|
#
|