discourse/lib/upcoming_changes.rb
Martin Brennan af5a05c280
FEATURE: Enable high context topic cards in Horizon by default (#39959)
This commit introduces a `enable_horizon_high_context_topic_cards`
upcoming change, which when enabled, will toggle the high context
topic cards to enabled for the Horizon theme. Existing sites will
have the option disabled by default with a legacy DB entry in
the theme_settings table.

New sites will have high context topic cards enabled by default
via the theme settings yml file.

The upcoming change is conditional:

* On brand new sites, we do not need the Upcoming Change because they’ll
be starting off with the new default.
* On existing sites…
* If Horizon is not in use (i.e. Theme is enabled by default and Theme
can be selected by users are both disabled), we do not need the Upcoming
Change. No one on the site is really impacted by this change.
* If Horizon is the default theme (i.e. Theme is enabled by default is
enabled; Theme can be selected by users is enabled or disabled), we do
need the Upcoming Change. Many users on the site will be impacted by
this change.
* If Horizon is user-selectable but not the default theme (i.e. Theme is
enabled by default is disabled but Theme can be selected by users is
enabled), we do need the Upcoming Change. Perhaps not many users will be
impacted, but there’s still some impact so I figure better to be safe.
2026-05-15 10:50:42 +10:00

445 lines
16 KiB
Ruby

# frozen_string_literal: true
module UpcomingChanges
# Some upcoming changes make no sense to display to admins,
# for example ones related to Horizon theme makes no sense to
# display if Horizon is not installed or is disabled, a change
# might modify how site behavior works if another setting is enabled,
# and so on.
#
# You can define any should_display_<upcoming_change_name>? method to control
# whether an upcoming change should be displayed to admins, and if the
# method is undefined the change will always be displayed.
#
# Keep in mind this is called from UpcomingChanges::List service,
# which loops over every change in an N1 depending on the filters admins
# have selected, so caching may be appropriate at times.
class ConditionalDisplay
def self.should_display?(upcoming_change_name)
if respond_to?("should_display_#{upcoming_change_name}?")
return public_send("should_display_#{upcoming_change_name}?")
end
true
end
def self.should_display_enable_horizon_high_context_topic_cards?
Themes::Action::HorizonHighContextTopicCardsToggled.should_display_upcoming_change?
end
end
def self.user_enabled_reasons
@user_enabled_reasons ||=
::Enum.new(
enabled_for_everyone: :enabled_for_everyone,
enabled_for_no_one: :enabled_for_no_one,
in_specific_groups: :in_specific_groups,
not_in_specific_groups: :not_in_specific_groups,
)
end
def self.statuses
@statuses ||=
::Enum.new(
conceptual: -100,
experimental: 0,
alpha: 100,
beta: 200,
stable: 300,
permanent: 500,
never: 9999,
)
end
# Mostly used for testing, to allow stubbing the SiteSetting provider,
# like for SiteSettingExtension spec. This is not ideal, but the SiteSettingExtension spec
# is extremely gnarly.
def self.settings_provider
SiteSetting
end
def self.previous_status_value(status)
status_value = self.statuses[status.to_sym]
self.statuses.values.select { |value| value < status_value }.max || -100
end
def self.previous_status(status)
self.statuses.keys.select { |key| self.statuses[key] < self.statuses[status.to_sym] }.last ||
:conceptual
end
def self.image_exists?(change_setting_name)
File.exist?(File.join(Rails.public_path, self.image_path(change_setting_name)))
end
def self.image_path(change_setting_name)
plugin_name = settings_provider.plugins[change_setting_name.to_sym]
if plugin_name.present?
File.join("plugins", plugin_name, "images", "upcoming_changes", "#{change_setting_name}.png")
else
File.join("images", "upcoming_changes", "#{change_setting_name}.png")
end
end
def self.image_data(change_setting_name, include_file_path: false)
width, height = nil, nil
full_file_path = File.join(Rails.public_path, image_path(change_setting_name))
File.open(full_file_path, "rb") do |file|
image_info = FastImage.new(file)
width, height = image_info.size
end
data = { url: "#{Discourse.base_url}/#{image_path(change_setting_name)}", width:, height: }
data[:file_path] = full_file_path if include_file_path
data
end
def self.change_metadata(change_setting_name)
change_setting_name = change_setting_name.to_sym
settings_provider.upcoming_change_metadata[change_setting_name] || {}
end
def self.not_yet_stable?(change_setting_name)
change_status_value(change_setting_name) < UpcomingChanges.statuses[:stable]
end
def self.stable_or_permanent?(change_setting_name)
change_status_value(change_setting_name) >= UpcomingChanges.statuses[:stable]
end
def self.meets_or_exceeds_status?(change_setting_name, status)
change_status_value(change_setting_name) >= UpcomingChanges.statuses[status]
end
def self.change_status_value(change_setting_name)
UpcomingChanges.statuses[change_status(change_setting_name)]
end
def self.change_status(change_setting_name)
change_metadata(change_setting_name)[:status]
end
def self.history_for(change_setting_name)
change_setting_name = change_setting_name.to_sym
UserHistory.where(
action: UserHistory.actions[:upcoming_change_toggled],
subject: change_setting_name,
).order(created_at: :desc)
end
def self.exists?(change_setting_name)
change_metadata(change_setting_name.to_sym).present?
end
# We dynamically determine if an upcoming change is enabled
# or disabled based on the current status of the change as well
# as whether the admin has manually toggled the change.
#
# @param change_setting_name [Symbol] The name of the upcoming change
# @return [Boolean]
def self.enabled?(change_setting_name)
change_setting_name = change_setting_name.to_sym
if !exists?(change_setting_name)
raise ArgumentError, "Unknown upcoming change: #{change_setting_name}"
end
# An admin has modified the setting and a value is stored
# in the database, since the default for upcoming changes
# is false.
#
# If the change is permanent though, the admin has no choice
# in the matter.
if settings_provider.setting_modified_from_default?(change_setting_name) &&
UpcomingChanges.change_status(change_setting_name) != :permanent
settings_provider.current[change_setting_name]
# The change has reached the promotion status and is forcibly
# enabled, admins can still disable it.
elsif UpcomingChanges.meets_or_exceeds_status?(
change_setting_name,
settings_provider.promote_upcoming_changes_on_status.to_sym,
) || UpcomingChanges.change_status(change_setting_name) == :permanent
true
else
# Otherwise use the default value, which for upcoming changes
# is false.
settings_provider.defaults[change_setting_name]
end
end
# The `allow_enabled_for` metadata for an upcoming change, or nil if unset.
# When nil, every "Enabled for" dropdown option is permitted. Otherwise it
# is an array containing any subset of [:everyone, :staff, :specific_groups].
def self.allow_enabled_for(change_setting_name)
change_metadata(change_setting_name)[:allow_enabled_for]
end
# Whether a setting's `allow_enabled_for` permits a given dropdown target.
# `:no_one` is always allowed. Returns true when the metadata is absent.
def self.target_allowed?(change_setting_name, target)
return true if target.to_sym == :no_one
allow = allow_enabled_for(change_setting_name)
return true if allow.nil?
allow.include?(target.to_sym)
end
# True when the setting's `allow_enabled_for` permits any group-based target
# (`:staff` or `:specific_groups`). When metadata is absent, groups are allowed.
def self.groups_target_allowed?(change_setting_name)
allow = allow_enabled_for(change_setting_name)
return true if allow.nil?
allow.include?(:staff) || allow.include?(:specific_groups)
end
def self.has_groups?(change_setting_name)
group_ids_for(change_setting_name).present?
end
def self.group_ids_for(change_setting_name)
change_setting_name = change_setting_name.to_sym
settings_provider.site_setting_group_ids[change_setting_name].presence || []
end
# Checks if a given upcoming change is enabled for a user,
# which can be either enabled for everyone, enabled for certain groups,
# or disabled for everyone. The user's group membership is used to determine
# if the upcoming change is enabled for them if the upcoming change is
# enabled for certain groups.
#
# @param change_setting_name [Symbol] The name of the upcoming change
# @param user [User] The user to check if the upcoming change is enabled for
# @return [Boolean]
def self.enabled_for_user?(change_setting_name, user)
change_setting_name = change_setting_name.to_sym
setting_enabled = UpcomingChanges.enabled?(change_setting_name)
# Anon users can only have upcoming changes enabled if it's set for Everyone
if user.blank?
return false if UpcomingChanges.has_groups?(change_setting_name)
else
if UpcomingChanges.has_groups?(change_setting_name)
return(
setting_enabled && user.in_any_groups?(UpcomingChanges.group_ids_for(change_setting_name))
)
end
end
setting_enabled
end
# Calculates the current state of all upcoming changes for a given user,
# including the reason why a change is or isn't enabled for them, and
# if it's due to group membership, which groups are relevant.
#
# The acting_guardian is used to determine group visibility. This is
# mostly used to show a list of upcoming changes for a user in the admin
# interface.
#
# @param user [User] The user to get the upcoming changes for
# @param acting_guardian [Guardian] The current user's guardian
# @return [Array<Hash>]
#
# @example
# stats_for_user(user: user, acting_guardian: admin)
# # => [
# # {
# # name: "new_feature",
# # humanized_name: "New Feature",
# # description: "This is a new feature",
# # enabled: true,
# # specific_groups: ["Group 1", "Group 2"],
# # reason: :in_specific_groups
# # },
# # {
# # name: "another_feature",
# # humanized_name: "Another Feature",
# # description: "This is another feature",
# # enabled: false,
# # specific_groups: [],
# # reason: :enabled_for_no_one
# # },
# # ]
def self.stats_for_user(user:, acting_guardian:)
guardian_visible_group_ids = Group.visible_groups(acting_guardian.user).pluck(:id)
user_belonging_to_group_ids = user.belonging_to_group_ids
settings_provider.upcoming_change_site_settings.filter_map do |name|
next if UpcomingChanges.change_status(name) == :conceptual
enabled = user.upcoming_change_enabled?(name)
has_groups = UpcomingChanges.has_groups?(name)
specific_groups = []
reason =
if has_groups
visible_group_ids =
UpcomingChanges.group_ids_for(name) & guardian_visible_group_ids &
user_belonging_to_group_ids
specific_groups = Group.where(id: visible_group_ids).pluck(:name)
if enabled
UpcomingChanges.user_enabled_reasons[:in_specific_groups]
else
UpcomingChanges.user_enabled_reasons[:not_in_specific_groups]
end
elsif enabled
UpcomingChanges.user_enabled_reasons[:enabled_for_everyone]
else
UpcomingChanges.user_enabled_reasons[:enabled_for_no_one]
end
{
name:,
humanized_name: settings_provider.humanized_name(name),
description: settings_provider.description(name),
enabled:,
specific_groups:,
reason:,
}
end
end
# For a given setting, we need to determine the enabled for value
# for the UI based on the setting value, and if the setting is enabled
# for certain groups, we need the actual group records to display in the UI.
# Mostly a utility method.
#
# @param setting_name [Symbol] The name of the setting
# @param setting_value [Boolean] The value of the setting
# @param upcoming_change_selected_groups [Hash] A hash of group ids to group names
# across all upcoming changes.
# @return [Hash] The enabled for value and the setting groups
#
# @example
# enabled_for_with_groups(:new_feature, true, { 1 => "Group 1", 2 => "Group 2" })
def self.enabled_for_with_groups(setting_name, setting_value, upcoming_change_selected_groups)
group_ids_for_setting = settings_provider.site_setting_group_ids[setting_name]
setting_groups =
upcoming_change_selected_groups.values_at(*group_ids_for_setting).join(
",",
) if group_ids_for_setting.present?
enabled_for =
if !setting_value
"no_one"
elsif setting_groups.blank?
# When `allow_enabled_for` excludes `:everyone` and the change is enabled
# without an admin-configured scope (typically because it was auto-promoted
# past the promotion threshold) we surface the broadest allowed target as
# the dropdown's selected value, since `"everyone"` is no longer a valid
# option. Backend access (`enabled_for_user?`) is unchanged — until the
# admin picks a scope, the change is still effectively on for everyone.
allow = allow_enabled_for(setting_name)
if allow.nil? || allow.include?(:everyone)
"everyone"
elsif allow.include?(:staff)
# Have to do this because the staff auto group name is localized
upcoming_change_selected_groups[Group::AUTO_GROUPS[:staff]]
else
"groups"
end
else
if group_ids_for_setting == [Group::AUTO_GROUPS[:staff]]
# Have to do this because the staff auto group name is localized
upcoming_change_selected_groups[Group::AUTO_GROUPS[:staff]]
else
"groups"
end
end
{ enabled_for:, setting_groups: }
end
def self.clear_caches!
Discourse.cache.delete(current_statuses_cache_key)
Discourse.cache.delete(permanent_upcoming_changes_cache_key)
DiscourseUpdates.clear_latest_new_feature_created_at_cache
end
def self.current_statuses_cache_key
"upcoming_changes_current_statuses::#{Discourse.git_version}"
end
# This also only changes once per deploy, so we can cache to the git version
# to save time in other places in the codebase when we have to figure out
# when an upcoming change moved to its current status.
#
# This cache is automatically cleared when UpcomingChanges::Action::TrackNotifyStatusChanges
# is called, since that adds new UpcomingChangeEvent records.
def self.current_statuses
Discourse
.cache
.fetch(current_statuses_cache_key) do
results = DB.query(<<-SQL, status_changed: UpcomingChangeEvent.event_types[:status_changed])
WITH latest_status_changes AS (
SELECT upcoming_change_name, MAX(created_at) as created_at
FROM upcoming_change_events
WHERE event_type = :status_changed
GROUP BY upcoming_change_name
ORDER BY MAX(created_at) DESC
)
SELECT latest_status_changes.upcoming_change_name, latest_status_changes.created_at, upcoming_change_events.event_data->>'new_value' as new_value
FROM latest_status_changes
INNER JOIN upcoming_change_events ON upcoming_change_events.upcoming_change_name = latest_status_changes.upcoming_change_name AND upcoming_change_events.created_at = latest_status_changes.created_at
ORDER BY latest_status_changes.created_at DESC
SQL
results.each_with_object({}) do |result, statuses|
statuses[result.upcoming_change_name] = {
status: result.new_value,
changed_at: result.created_at,
}
end
end
end
def self.permanent_upcoming_changes_cache_key
"upcoming_changes_permanent::#{Discourse.git_version}"
end
# These don't change except on deploy, so we can cache to the git version
# to save time in other places in the codebase when we have to figure out
# whether a change is permanent or not.
def self.permanent_upcoming_changes
Discourse
.cache
.fetch(permanent_upcoming_changes_cache_key) do
result =
UpcomingChanges::List.call(
guardian: Discourse.system_user.guardian,
options: {
filter_statuses: [:permanent],
},
)
if !result.success? && Rails.env.local?
puts result.inspect_steps
raise
end
result.upcoming_changes
end
end
# No point in notifying admins on brand new sites, the upcoming change system
# is more about notifying admins of changes to established sites.
#
# Of course we don't care about this in development, we need to test notifications,
# and we can stub this method in rspec.
def self.should_notify_admins?
Migration::Helpers.existing_site? || Rails.env.development?
end
# Some upcoming changes have a depends_on relationship with other settings,
# where it doesn't make sense to show the dependent settings in the site
# settings UI unless the upcoming change is enabled.
#
# This is done via depends_on and depends_behavior: hidden in site_settings.yml.
def self.find_dependents_for_change(change_setting_name)
settings_provider.type_supervisor.dependencies.dependents(change_setting_name.to_s)
end
end