discourse/app/models/category.rb
Bianca Nenciu dcd81d56c0
FIX: category selectors for lazy loaded categories (#24533)
A lot of work has been put in the select kits used for selecting
categories: CategorySelector, CategoryChooser, CategoryDrop, however
they still do not work as expected when these selectors already have
values set, because the category were still looked up in the list of
categories stored on the client-side Categrories.list().

This PR fixes that by looking up the categories when the selector is
initialized. This required altering the /categories/find.json endpoint
to accept a list of IDs that need to be looked up. The API is called
using Category.asyncFindByIds on the client-side.

CategorySelector was also updated to receive a list of category IDs as
attribute, instead of the list of categories, because the list of
categories may have not been loaded.

During this development, I noticed that SiteCategorySerializer did not
serializer all fields (such as permission and notification_level)
which are not a property of category, but a property of the relationship
between users and categories. To make this more efficient, the
preload_user_fields! method was implemented that can be used to
preload these attributes for a user and a list of categories.
2023-12-08 12:01:08 +02:00

1247 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
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.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.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?
@has_children ||= (id && Category.where(parent_category_id: id).exists?) ? :true : :false
@has_children == :true
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) 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)
#