0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-06 02:51:10 +08:00
discourse/lib/upcoming_changes.rb
David Battersby 6d51f7b4f8
DEV: remove enable gifs upcoming change metadata (#41798)
We want to allow site admins to enable or disable the GIFs component, so
we will retire this setting at `stable` and remove the upcoming change
metadata. This setting will now be visible within the GIFs panel under
the Appearance section in the admin sidebar.

Intentionally keeping the migration rake task
(`lib/tasks/migrate_discourse_gifs_to_core.rake`) for a few more weeks
to account for any self-hosters who may not have migrated yet. We will
follow up in a few weeks to remove it entirely.
2026-07-20 12:30:00 +04:00

599 lines
24 KiB
Ruby
Vendored

# 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.
#
# Core can define any should_display_<upcoming_change_name>? method to control
# whether an upcoming change should be displayed to admins. Plugins can use
# Plugin::Instance#register_upcoming_change_conditional_display for their own
# upcoming changes. If no conditional display rule is defined, the change will
# always be displayed.
#
# A plugin-owned change is hidden, and never takes effect, while its owning
# plugin is disabled -- unless it opts out with `requires_plugin_enabled: false`.
# See .owning_plugin_enabled?
#
# 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)
upcoming_change_name = upcoming_change_name.to_sym
return false if !UpcomingChanges.owning_plugin_configurable?(upcoming_change_name)
return false if !UpcomingChanges.owning_plugin_enabled?(upcoming_change_name)
if respond_to?("should_display_#{upcoming_change_name}?")
return public_send("should_display_#{upcoming_change_name}?")
end
callbacks =
DiscoursePluginRegistry.upcoming_change_conditional_display_callbacks.select do |callback|
callback[:setting_name] == upcoming_change_name
end
callbacks.empty? || callbacks.all? { |callback| callback[:callback].call }
end
def self.should_display_enable_horizon_high_context_topic_cards?
Themes::Action::HorizonHighContextTopicCardsToggled.should_display_upcoming_change?
end
# Only relevant on sites that currently allow uncategorized topics, and must
# stay visible after being enabled (which disables that setting).
def self.should_display_remove_and_replace_uncategorized?
SiteSetting::Action::RemoveAndReplaceUncategorizedToggled.should_display_upcoming_change?
end
# Code login is a delivery variant of email login (see
# EnableLocalLoginsViaCodeValidator), so the change is only actionable when
# local logins via email are possible. Must stay visible once enabled so
# admins can still find and disable it.
def self.should_display_enable_local_logins_via_code?
return true if UpcomingChanges.enabled?(:enable_local_logins_via_code)
SiteSetting.enable_local_logins && SiteSetting.enable_local_logins_via_email &&
!SiteSetting.enable_discourse_connect
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 = statuses[status.to_sym]
statuses.values.select { |value| value < status_value }.max || -100
end
def self.previous_status(status)
statuses.keys.select { |key| statuses[key] < statuses[status.to_sym] }.last || :conceptual
end
def self.next_status(status)
status = status&.to_sym
status_value = statuses[status]
return if status_value.nil?
return if status_value < statuses[:experimental] || status_value >= statuses[:stable]
statuses.keys.find { |key| statuses[key] > status_value }
end
def self.image_exists?(change_setting_name)
File.exist?(File.join(Rails.public_path, 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
# An upcoming change owned by a plugin that is not configurable on this site,
# is never available. It must not be displayed, notified about, or enabled.
#
# This is deliberately broader than the guard in SiteSettingExtension#setting,
# which only forces a plugin's own enabled_site_setting to false. A change
# gating a sub-feature of an unavailable plugin is equally unavailable.
#
# Core changes have no owning plugin and return early, so the common case
# never reaches #configurable? and adds no cost to callers on hot paths like
# .settings_hidden_while_enabled.
def self.owning_plugin_configurable?(change_setting_name)
plugin_name = settings_provider.plugins[change_setting_name.to_sym]
return true if plugin_name.nil?
Discourse.plugins_by_name[plugin_name]&.configurable? != false
end
# Whether a plugin-owned change is gated on the plugin being enabled.
# By default: upcoming changes in plugins are neither displayed nor take effect.
# A plugin change can opt OUT with `requires_plugin_enabled: false`.
def self.requires_plugin_enabled?(change_setting_name)
change_metadata(change_setting_name)[:requires_plugin_enabled] != false
end
def self.owning_plugin_enabled?(change_setting_name)
change_setting_name = change_setting_name.to_sym
return true if !requires_plugin_enabled?(change_setting_name)
plugin_name = settings_provider.plugins[change_setting_name]
return true if plugin_name.nil?
plugin = Discourse.plugins_by_name[plugin_name]
return true if plugin.nil?
# Recursion guard: a change that is its own plugin's enabled_site_setting
# must opt out with requires_plugin_enabled: false (enforced by the
# integrity spec), but if that metadata is forgotten, plugin.enabled? below
# would read the change setting, which resolves back through
# UpcomingChanges.enabled? and recurses infinitely.
return true if plugin.enabled_site_setting&.to_sym == change_setting_name
plugin.enabled? != false
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
# The owning plugin is not available on this site, so neither is the change.
# This intentionally takes precedence over the :permanent status below, since
# a permanent change to an unavailable plugin still cannot take effect.
return false if !owning_plugin_configurable?(change_setting_name)
# The owning plugin is disabled, so the change must not take effect either.
# Without this guard an opted-in change would keep acting on the site after
# its plugin is switched off -- its `hide_settings` stay hidden and its
# default overrides stay applied -- while ConditionalDisplay (which also
# checks this) hides the change from admins, leaving them no way to see
# what is causing those effects. The stored opt-in is deliberately left
# untouched, so the change resumes when the plugin is re-enabled.
return false if !owning_plugin_enabled?(change_setting_name)
# 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::TrackStatusChanges
# 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
# The setting names of all permanent upcoming changes. Used on the frontend
# to decide whether a notification should link to the upcoming changes page
# or to the What's New page (where permanent changes are surfaced).
def self.permanent_upcoming_change_names
permanent_upcoming_changes.map { |uc| uc[:setting].to_s }
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
# Whether the settings the change itself depends_on (in site_settings.yml)
# currently hold the values the change needs. Used by the admin UI to warn
# admins when a change's prerequisites are not met, since enabling the change
# would have no effect (or be rejected by a validator) until they are.
def self.change_dependencies_met?(change_setting_name)
dependencies = settings_provider.type_supervisor.dependencies[change_setting_name.to_sym]
return true if dependencies.blank?
allowed_values = settings_provider.dependency_values[change_setting_name.to_sym]
dependencies.all? do |dependency|
value = settings_provider.public_send(dependency)
if (allowed = allowed_values&.dig(dependency))
allowed.include?(value.to_s)
else
value == true
end
end
end
def self.including_css
settings_provider.upcoming_change_site_settings.filter_map do |upcoming_change|
upcoming_change if settings_provider.upcoming_change_metadata[upcoming_change][:body_class]
end
end
# Site settings to hide from admins because an upcoming change that declares
# `hide_settings:` (in its site_settings.yml metadata) is currently enabled.
# Legacy settings a change replaces stop making sense once the change is in
# effect, so they are hidden while it is enabled.
#
# Consulted by SiteSettings::HiddenProvider#all, which runs on every
# hidden_settings read. It is computed live rather than toggled at opt-in time
# so it tracks both opt-in paths (manual and auto-promotion) and is
# multisite-safe: the hidden set is process-global, but enabled? resolves
# per-site, so we never hide a setting for sites that haven't opted in.
#
# `enabled?` is only called for the (usually zero) changes that declare
# `hide_settings`, so the common case is a cheap metadata scan with no DB hit.
def self.settings_hidden_while_enabled
metadata = settings_provider.upcoming_change_metadata
return [] if metadata.empty?
metadata.each_with_object([]) do |(change_name, change_metadata), hidden|
next if change_metadata[:hide_settings].blank?
hidden.concat(change_metadata[:hide_settings]) if enabled?(change_name)
end
end
end