discourse/app/controllers/categories_controller.rb
Renato Atilio eaca2d8a59
FEATURE: Add "Events" category type for Calendar plugin (#39727)
Adds an Events category type alongside Support and Ideas in the
simplified category creation flow. Auto-enables Calendar + Discourse
Post Event, registers the category in events_calendar_categories, and
writes a matching calendar_categories entry. Gated behind
enable_events_category_type_setup.

The Events tab exposes: default calendar view, display weekends, allowed
groups, event date format, topic sorting, and the upcoming events
sidebar link.

Framework support for per-category type settings:

- CategoriesController#create and #update now accept
category_type_settings, slicing the payload by each type's own
configuration_schema_keys(:category_settings). On create the slice
merges into Categories::Configure's category_configuration_values; on
update the controller iterates the category's attached types and calls
configure_category per type, so per-type fields persist on edit and a
type only ever sees its own keys.

- Categories::Types::Base#read_category_settings is a new extension
point types override to return current per-category values; the
CategorySerializer aggregates these as category_type_settings so the
edit form preloads stored values rather than schema defaults.

- Categories::Types::Base forwards :choices through the resolved schema
and lets a type override site setting :type and :choices when it wants a
bool to render as a labelled enum. configure_custom_fields and
configure_site_settings tolerate string or symbol keys.

- SchemaFormField gains enum (FormKit select) and group_list
(GroupChooser) branches. EditCategoryTypeSchemaFields renders a new form
section bound to a category_type_settings form object that iterates
schema.category_settings, gated by a hasCategorySettings getter. tabs.js
seeds the bucket from model.category_type_settings plus schema defaults;
Category#_categoryTypeSaveProperties sends it on save.

---------

Co-authored-by: Martin Brennan <martin@discourse.org>
2026-05-15 18:24:16 -03:00

865 lines
28 KiB
Ruby
Vendored

# frozen_string_literal: true
class CategoriesController < ApplicationController
include TopicQueryParams
requires_login except: %i[
index
categories_and_latest
categories_and_top
categories_and_hot
show
redirect
find_by_slug
visible_groups
find
search
]
before_action :fetch_category, only: %i[show update destroy visible_groups]
before_action :initialize_staff_action_logger, only: %i[create update destroy]
skip_before_action :check_xhr,
only: %i[
index
categories_and_latest
categories_and_top
categories_and_hot
redirect
]
skip_before_action :verify_authenticity_token, only: %i[search]
# The front-end is POSTing data to this endpoint, but we're not modifying anything
allow_in_readonly_mode :search
SYMMETRICAL_CATEGORIES_TO_TOPICS_FACTOR = 1.5
MIN_CATEGORIES_TOPICS = 5
MAX_CATEGORIES_TOPICS = 100
MAX_CATEGORIES_LIMIT = 25
def redirect
return if handle_permalink("/category/#{params[:path]}")
redirect_to path("/c/#{params[:path]}")
end
def index
discourse_expires_in 1.minute
@category_list = fetch_category_list
respond_to do |format|
format.html do
@title =
if current_homepage == "categories" && SiteSetting.short_site_description.present?
"#{SiteSetting.title} - #{SiteSetting.short_site_description}"
elsif current_homepage != "categories"
"#{I18n.t("js.filters.categories.title")} - #{SiteSetting.title}"
end
@description = SiteSetting.site_description
store_preloaded(
@category_list.preload_key,
MultiJson.dump(CategoryListSerializer.new(@category_list, scope: guardian)),
)
@topic_list = fetch_topic_list
if @topic_list.present? && @topic_list.topics.present?
store_preloaded(
@topic_list.preload_key,
MultiJson.dump(TopicListSerializer.new(@topic_list, scope: guardian)),
)
end
render
end
format.json { render_serialized(@category_list, CategoryListSerializer) }
end
end
def categories_and_latest
categories_and_topics(:latest)
end
def categories_and_top
categories_and_topics(:top)
end
def categories_and_hot
categories_and_topics(:hot)
end
def move
guardian.ensure_can_create_category!
params.require("category_id")
params.require("position")
if category = Category.find(params["category_id"])
guardian.ensure_can_see!(category)
category.move_to(params["position"].to_i)
render json: success_json
else
render status: :internal_server_error, json: failed_json
end
end
def reorder
guardian.ensure_can_create_category!
params.require(:mapping)
change_requests = MultiJson.load(params[:mapping])
by_category = Hash[change_requests.map { |cat, pos| [Category.find(cat.to_i), pos] }]
unless guardian.is_admin?
unless by_category.keys.all? { |c| guardian.can_see_category? c }
raise Discourse::InvalidAccess
end
end
by_category.each do |cat, pos|
cat.position = pos
cat.save! if cat.will_save_change_to_position?
end
render json: success_json
end
def types
guardian.ensure_can_create_category!
counts_by_type =
Discourse
.cache
.fetch(Categories::TypeRegistry::COUNTS_CACHE_KEY, expires_in: 1.hour) do
Categories::TypeRegistry.counts
end
render json: {
types: Categories::TypeRegistry.list(only_visible: true),
counts: counts_by_type,
}
end
def show
guardian.ensure_can_see!(@category)
if Category.topic_create_allowed(guardian).where(id: @category.id).exists?
@category.permission = CategoryGroup.permission_types[:full]
end
render_serialized(@category, CategorySerializer)
end
MAX_DESCRIPTION_PARAM_LENGTH = 1000
def create
guardian.ensure_can_create!(Category)
position = category_params.delete(:position)
category_type = params[:category_type]
if category_params[:description].present? &&
category_params[:description].size > MAX_DESCRIPTION_PARAM_LENGTH
render json: {
errors: [
I18n.t(
"category.errors.description_too_long",
count: MAX_DESCRIPTION_PARAM_LENGTH,
),
],
},
status: :unprocessable_entity
return
end
@category =
begin
Category.new(required_create_params.merge(user: current_user))
rescue ArgumentError => e
return render json: { errors: [e.message] }, status: :unprocessable_entity
end
if @category.save
@category.move_to(position.to_i) if position
if category_type.present? &&
UpcomingChanges.enabled_for_user?(:enable_simplified_category_creation, current_user)
type_class = Categories::TypeRegistry.get(category_type.to_sym)
allowed_setting_keys = type_class&.configuration_schema_keys(:category_settings) || []
type_settings = params[:category_type_settings]&.slice(*allowed_setting_keys)&.permit!
Categories::Configure.call(
guardian:,
params: {
category_id: @category.id,
category_type:,
site_setting_configuration_values: params[:category_type_site_settings],
category_configuration_values: [
category_params[:custom_fields],
type_settings,
].compact.reduce(:merge),
},
) do |result|
on_failed_policy(:type_is_available) do
return(
render json: {
errors: [
I18n.t(
"category_types.not_available",
type_name: category_type.capitalize,
),
],
},
status: :unprocessable_entity
)
end
end
end
Scheduler::Defer.later "Log staff action create category" do
@staff_action_logger.log_category_creation(@category)
end
render_serialized(@category, CategorySerializer)
else
render_json_error(@category)
end
end
def update
guardian.ensure_can_edit!(@category)
json_result(@category, serializer: CategorySerializer) do |cat|
old_category_params = category_params.dup
cat.move_to(category_params[:position].to_i) if category_params[:position]
category_params.delete(:position)
old_custom_fields = cat.custom_fields.dup
pending_custom_fields = category_params[:custom_fields]
category_params.delete(:custom_fields)
# Handles adding or removing category types registered by plugins
# based on the multi-type selector in the General tab for categories.
if UpcomingChanges.enabled_for_user?(:enable_simplified_category_creation, current_user)
manage_category_types(cat, pending_custom_fields || {})
cat.reload
end
if UpcomingChanges.enabled_for_user?(:enable_simplified_category_creation, current_user) &&
params[:category_type_settings].present?
# Re-run configure_category for each matching type so per-type
# category_settings (e.g. events_calendar_default_view) are persisted on
# edit, not just on create. Each type only sees its own keys so it
# cannot be invoked spuriously for other types' settings.
cat.category_types.each_key do |type_id|
type_class = Categories::TypeRegistry.get(type_id)
next unless type_class
type_values =
params[:category_type_settings].slice(
*type_class.configuration_schema_keys(:category_settings),
)
next if type_values.empty?
type_class.configure_category(cat, guardian:, configuration_values: type_values)
end
end
merge_pending_custom_fields!(cat, pending_custom_fields)
# properly null the value so the database constraint doesn't catch us
category_params[:email_in] = nil if category_params[:email_in]&.blank?
category_params[:minimum_required_tags] = 0 if category_params[:minimum_required_tags]&.blank?
old_permissions = cat.permissions_params
old_permissions = { Group[:everyone].name => 1 } if old_permissions.empty?
if result = cat.update(category_params)
Category.preload_user_fields!(guardian, [cat])
Scheduler::Defer.later "Log staff action change category settings" do
@staff_action_logger.log_category_settings_change(
@category,
old_category_params,
old_permissions: old_permissions,
old_custom_fields: old_custom_fields,
)
end
end
DiscourseEvent.trigger(:category_updated, cat) if result
result
end
end
def update_slug
@category = Category.find(params[:category_id].to_i)
guardian.ensure_can_edit!(@category)
custom_slug = params[:slug].to_s
if custom_slug.blank?
error = @category.errors.full_message(:slug, I18n.t("errors.messages.blank"))
render_json_error(error)
elsif @category.update(slug: custom_slug)
render json: success_json
else
render_json_error(@category)
end
end
def set_notifications
category_id = params[:category_id].to_i
notification_level = params[:notification_level].to_i
CategoryUser.set_notification_level_for_category(current_user, notification_level, category_id)
render json:
success_json.merge(
{
indirectly_muted_category_ids:
CategoryUser.indirectly_muted_category_ids(current_user),
},
)
end
def destroy
guardian.ensure_can_delete!(@category)
@category.destroy
Discourse.cache.delete(Categories::TypeRegistry::COUNTS_CACHE_KEY)
Scheduler::Defer.later "Log staff action delete category" do
@staff_action_logger.log_category_deletion(@category)
end
render json: success_json
end
def find_by_slug
params.require(:category_slug)
@category =
Category.includes(:category_setting).find_by_slug_path(params[:category_slug].split("/"))
raise Discourse::NotFound if @category.blank?
if !guardian.can_see?(@category)
if SiteSetting.detailed_404 && group = @category.access_category_via_group
raise Discourse::InvalidAccess.new(
"not in group",
@category,
custom_message: "not_in_group.title_category",
custom_message_params: {
group: group.name,
},
group: group,
)
else
raise Discourse::NotFound
end
end
@category.permission = CategoryGroup.permission_types[:full] if Category
.topic_create_allowed(guardian)
.where(id: @category.id)
.exists?
Category.preload_user_fields!(guardian, [@category])
render_serialized(@category, CategorySerializer)
end
def visible_groups
@guardian.ensure_can_see!(@category)
groups =
if !@category.groups.exists?(id: Group::AUTO_GROUPS[:everyone])
@category.groups.merge(Group.visible_groups(current_user)).pluck("name")
end
render json: success_json.merge(groups: groups || [])
end
def find
categories = []
serializer = params[:include_permissions] ? CategorySerializer : SiteCategorySerializer
if params[:ids].present?
categories = Category.secured(guardian).where(id: params[:ids])
elsif params[:slug_path].present?
category = Category.find_by_slug_path(params[:slug_path].split("/"))
raise Discourse::NotFound if category.blank?
guardian.ensure_can_see!(category)
ancestors = Category.secured(guardian).with_ancestors(category.id).where.not(id: category.id)
categories = [*ancestors, category]
elsif params[:slug_path_with_id].present?
category = Category.find_by_slug_path_with_id(params[:slug_path_with_id])
raise Discourse::NotFound if category.blank?
guardian.ensure_can_see!(category)
ancestors = Category.secured(guardian).with_ancestors(category.id).where.not(id: category.id)
categories = [*ancestors, category]
end
raise Discourse::NotFound if categories.blank?
Category.preload_user_fields!(guardian, categories)
render_serialized(categories, serializer, root: :categories, scope: guardian)
end
def hierarchical_search
Category::HierarchicalSearch.call(service_params) do
on_success do |categories:|
render_json_dump(
categories: serialize_data(categories, SiteCategorySerializer, scope: guardian),
)
end
on_failed_contract do |contract|
render json: failed_json.merge(errors: contract.errors.full_messages), status: :bad_request
end
on_failure { render json: failed_json, status: :unprocessable_entity }
end
end
def search
term = params[:term].to_s.strip
parent_category_id = params[:parent_category_id].to_i if params[:parent_category_id].present?
include_uncategorized =
(
if params[:include_uncategorized].present?
ActiveModel::Type::Boolean.new.cast(params[:include_uncategorized])
else
true
end
)
if params[:select_category_ids].is_a?(Array)
select_category_ids = params[:select_category_ids].map(&:presence)
end
if params[:reject_category_ids].is_a?(Array)
reject_category_ids = params[:reject_category_ids].map(&:presence)
end
include_subcategories =
if params[:include_subcategories].present?
ActiveModel::Type::Boolean.new.cast(params[:include_subcategories])
else
true
end
include_ancestors =
if params[:include_ancestors].present?
ActiveModel::Type::Boolean.new.cast(params[:include_ancestors])
else
false
end
prioritized_category_id = params[:prioritized_category_id].to_i if params[
:prioritized_category_id
].present?
limit =
(
if params[:limit].present?
params[:limit].to_i.clamp(1, MAX_CATEGORIES_LIMIT)
else
MAX_CATEGORIES_LIMIT
end
)
page = [1, params[:page].to_i].max
categories = Category.secured(guardian)
if term.present? && words = term.split
words.each do |word|
categories =
categories.where(
"#{Category.normalize_sql("name")} ILIKE #{Category.normalize_sql("?")}",
"%#{word}%",
)
end
end
categories =
(
if parent_category_id != -1
categories.where(parent_category_id: parent_category_id)
else
categories.where(parent_category_id: nil)
end
) if parent_category_id.present?
categories =
categories.where.not(id: SiteSetting.uncategorized_category_id) if !include_uncategorized
categories = categories.where(id: select_category_ids) if select_category_ids
categories = categories.where.not(id: reject_category_ids) if reject_category_ids
categories = categories.where(parent_category_id: nil) if !include_subcategories
categories_count = categories.count
categories =
categories
.includes(
:uploaded_logo,
:uploaded_logo_dark,
:uploaded_background,
:uploaded_background_dark,
:tags,
:tag_groups,
:form_templates,
category_required_tag_groups: :tag_group,
)
.joins("LEFT JOIN topics t on t.id = categories.topic_id")
.select("categories.*, t.slug topic_slug")
.order(
"starts_with(#{Category.normalize_sql("categories.name")}, #{Category.normalize_sql(ActiveRecord::Base.connection.quote(term))}) DESC",
"categories.parent_category_id IS NULL DESC",
"categories.id IS NOT DISTINCT FROM #{ActiveRecord::Base.connection.quote(prioritized_category_id)} DESC",
"categories.parent_category_id IS NOT DISTINCT FROM #{ActiveRecord::Base.connection.quote(prioritized_category_id)} DESC",
"categories.id ASC",
)
.limit(limit)
.offset((page - 1) * limit)
if Site.preloaded_category_custom_fields.present?
Category.preload_custom_fields(categories, Site.preloaded_category_custom_fields)
end
Category.preload_user_fields!(guardian, categories)
response = {
categories_count: categories_count,
categories: serialize_data(categories, SiteCategorySerializer, scope: guardian),
}
if include_ancestors
ancestors = Category.secured(guardian).ancestors_of(categories.map(&:id))
Category.preload_user_fields!(guardian, ancestors)
response[:ancestors] = serialize_data(ancestors, SiteCategorySerializer, scope: guardian)
end
render_json_dump(response)
end
private
def merge_pending_custom_fields!(category, pending_custom_fields)
pending_custom_fields&.each do |key, value|
if value.nil? || value == ""
category.custom_fields.delete(key)
else
category.custom_fields[key] = if value.is_a?(TrueClass) || value.is_a?(FalseClass)
value.to_s
else
value
end
end
end
end
def manage_category_types(category, pending_custom_fields)
# NOTE: The code in this block is pretty similar to what we are doing in
# configure_site_settings in Categories::Types::Base, however here we
# need to be able to update site settings across all category types for
# the category and it's best to do this in one query rather than
# multiple.
#
# Maybe in future we do something different, but this is a good starting point.
if params[:category_type_site_settings].present?
category_type_settings =
params[:category_type_site_settings].permit!.to_h.map do |name, value|
{ setting_name: name, value: }
end
# We do this because we want to allow updating hidden settings for the
# category type, but not other settings. The configuration schema for a
# category type defines which settings it wants to change, so that's a
# good source to use as an allowlist here.
allowed_setting_names = category.category_type_site_setting_names
SiteSetting::Update.call(
guardian:,
options: {
allow_changing_hidden: allowed_setting_names,
},
params: {
settings: category_type_settings,
},
)
end
if params.has_key?(:category_types)
# Discussion can never be removed as a category type, so we always add it back.
new_category_types =
Array(params[:category_types]).compact_blank.map(&:to_sym) + [:discussion]
current_category_types = category.category_types.keys
removed_category_types = current_category_types - new_category_types
added_category_types = new_category_types - current_category_types
# Some category custom fields (like
# DiscourseSolved::ENABLE_ACCEPTED_ANSWERS_CUSTOM_FIELD) control whether
# the type is enabled or not, so we need to remove them from the pending
# custom fields to avoid turning the type back on/off again.
(added_category_types + removed_category_types).each do |category_type|
Categories::TypeRegistry
.get(category_type)
.configuration_schema_keys(:category_custom_fields)
.each { |custom_field_key| pending_custom_fields.delete(custom_field_key) }
end
removed_category_types.each do |category_type|
Categories::Unconfigure.call(
guardian:,
params: {
category_id: category.id,
category_type:,
},
)
end
added_category_types.each do |category_type|
Categories::Configure.call(
guardian:,
params: {
category_id: category.id,
category_type:,
},
) do |result|
on_failed_policy(:type_is_available) do
return(
render json: {
errors: [
I18n.t(
"category_types.not_available",
type_name: category_type.capitalize,
),
],
},
status: :unprocessable_entity
)
end
end
end
end
end
def topics_per_page
return SiteSetting.categories_topics if SiteSetting.categories_topics > 0
count = Category.secured(guardian).where(parent_category: nil).count
count = (SYMMETRICAL_CATEGORIES_TO_TOPICS_FACTOR * count).to_i
count.clamp(MIN_CATEGORIES_TOPICS, MAX_CATEGORIES_TOPICS)
end
def categories_and_topics(topics_filter)
discourse_expires_in 1.minute
result = CategoryAndTopicLists.new
result.category_list = fetch_category_list
result.topic_list = fetch_topic_list(topics_filter:)
render_serialized(result, CategoryAndTopicListsSerializer, root: false)
end
def required_param_keys
[:name]
end
def required_create_params
required_param_keys.each { |key| params.require(key) }
category_params
end
def category_params
@category_params ||=
begin
if p = params[:permissions]
p.each { |k, v| p[k] = v.to_i }
end
if SiteSetting.tagging_enabled
params[:allowed_tags] = params[:allowed_tags].presence || [] if params[:allowed_tags]
params[:allowed_tag_groups] = params[:allowed_tag_groups].presence || [] if params[
:allowed_tag_groups
]
params[:required_tag_groups] = params[:required_tag_groups].presence || [] if params[
:required_tag_groups
]
end
conditional_param_keys = []
if SiteSetting.enable_category_group_moderation?
conditional_param_keys << { moderating_group_ids: [] }
end
if SiteSetting.content_localization_enabled?
conditional_param_keys << :locale
conditional_param_keys << { category_localizations: %i[id locale name description] }
end
permitted_params = [
*required_param_keys,
:position,
:name,
:color,
:text_color,
:style_type,
:emoji,
:icon,
:email_in,
:email_in_allow_strangers,
:mailinglist_mirror,
:all_topics_wiki,
:allow_unlimited_owner_edits_on_first_post,
:default_slow_mode_seconds,
:parent_category_id,
:auto_close_hours,
:auto_close_based_on_last_post,
:uploaded_logo_id,
:uploaded_logo_dark_id,
:uploaded_background_id,
:uploaded_background_dark_id,
:slug,
:allow_badges,
:topic_template,
:topic_title_placeholder,
:description,
:sort_order,
:sort_ascending,
:topic_featured_link_allowed,
:show_subcategory_list,
:num_featured_topics,
:default_view,
:subcategory_list_style,
:default_top_period,
:minimum_required_tags,
:navigate_to_first_post_after_read,
:search_priority,
:allow_global_tags,
:read_only_banner,
:default_list_filter,
{ topic_posting_review_group_ids: [] },
{ reply_posting_review_group_ids: [] },
*conditional_param_keys,
]
DiscoursePluginRegistry.category_update_param_with_callback.each do |param_name, config|
permitted_params << param_name if config[:plugin].enabled?
end
result =
params.permit(
*permitted_params,
category_setting_attributes: %i[
auto_bump_cooldown_days
num_auto_bump_daily
require_reply_approval
require_topic_approval
nested_replies_default
topic_posting_review_mode
reply_posting_review_mode
],
custom_fields: {
},
permissions: [*p.try(:keys)],
allowed_tags: [],
allowed_tag_groups: [],
required_tag_groups: %i[name min_count],
form_template_ids: [],
)
if result[:required_tag_groups] && !result[:required_tag_groups].is_a?(Array)
raise Discourse::InvalidParameters.new(:required_tag_groups)
end
if @category
DiscoursePluginRegistry.category_update_param_with_callback.each do |param_name, config|
next if !config[:plugin].enabled?
next if !result.key?(param_name)
@category.instance_variable_set(:"@#{param_name}_callback_value", result[param_name])
# remove from params so that AR doesn't try to set it as an attribute
result.delete(param_name)
end
end
result
end
end
def fetch_category
@category = Category.find_by_slug(params[:id]) || Category.find_by(id: params[:id].to_i)
raise Discourse::NotFound if @category.blank?
end
def fetch_category_list
parent_category =
if params[:parent_category_id].present?
Category.find_by_slug(params[:parent_category_id]) ||
Category.find_by(id: params[:parent_category_id].to_i)
elsif params[:category_slug_path_with_id].present?
Category.find_by_slug_path_with_id(params[:category_slug_path_with_id])
end
include_topics =
params[:include_topics] ||
(parent_category && parent_category.subcategory_list_includes_topics?) ||
SiteSetting.desktop_category_page_style == "categories_with_featured_topics" ||
SiteSetting.desktop_category_page_style == "subcategories_with_featured_topics" ||
SiteSetting.desktop_category_page_style == "categories_boxes_with_topics" ||
SiteSetting.desktop_category_page_style == "categories_with_top_topics" ||
SiteSetting.mobile_category_page_style == "categories_with_featured_topics" ||
SiteSetting.mobile_category_page_style == "categories_boxes_with_topics" ||
SiteSetting.mobile_category_page_style == "subcategories_with_featured_topics"
include_subcategories =
SiteSetting.desktop_category_page_style == "subcategories_with_featured_topics" ||
SiteSetting.mobile_category_page_style == "subcategories_with_featured_topics" ||
params[:include_subcategories] == "true"
category_options = {
is_homepage: current_homepage == "categories",
parent_category_id: parent_category&.id,
include_topics: include_topics,
include_subcategories: include_subcategories,
tag: params[:tag],
page: params[:page].try(:to_i) || 1,
}
@category_list = CategoryList.new(guardian, category_options)
end
def fetch_topic_list(topics_filter: nil)
style =
if topics_filter
"categories_and_#{topics_filter}_topics"
else
SiteSetting.desktop_category_page_style
end
topic_options = { per_page: topics_per_page, no_definitions: true }
topic_options.merge!(build_topic_list_options)
topic_options[:order] = "created" if SiteSetting.desktop_category_page_style ==
"categories_and_latest_topics_created_date"
case style
when "categories_and_latest_topics", "categories_and_latest_topics_created_date"
@topic_list = TopicQuery.new(current_user, topic_options).list_latest
@topic_list.more_topics_url = url_for(latest_path(sort: topic_options[:order]))
when "categories_and_top_topics"
@topic_list =
TopicQuery.new(current_user, topic_options).list_top_for(
SiteSetting.top_page_default_timeframe.to_sym,
)
@topic_list.more_topics_url = url_for(top_path)
when "categories_and_hot_topics"
@topic_list = TopicQuery.new(current_user, topic_options).list_hot
@topic_list.more_topics_url = url_for(hot_path)
end
@topic_list
end
def initialize_staff_action_logger
@staff_action_logger = StaffActionLogger.new(current_user)
end
end