mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-05-02 10:37:59 +08:00
We often need to change site setting defaults when we would like to change default behaviour for the Discourse software. However, we want to be non-disruptive to existing sites, as some default changes can modify the behaviour of existing features quite a lot. In the past, when we did this we would write the old default to the database for anyone who hadn't changed the setting, and then change it for new sites going forward. However in practice means that there are large portions of Discourse sites with "bad" defaults that we no longer agree with, that slows adoption of best practices and makes it harder to reason about our different features. This change adds a way for upcoming changes to control the rollout of changes to site setting defaults via additional metadata attached to the setting whose default is changing. This way, we can have a more gradual rollout of new defaults, and we can also inform site admins about the upcoming change and give them a chance to opt in early if they want to.
386 lines
14 KiB
Ruby
386 lines
14 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module UpcomingChanges
|
|
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
|
|
|
|
# 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
|
|
|
|
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?
|
|
"everyone"
|
|
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)
|
|
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
|
|
|
|
# Some upcoming changes when enabled will override the default value
|
|
# of another setting.
|
|
#
|
|
# This is done via upcoming_change_default_override in site_settings.yml.
|
|
def self.find_related_default_override_for_change(change_setting_name)
|
|
settings_provider
|
|
.upcoming_change_default_overrides
|
|
.find { |_, override| override[:upcoming_change] == change_setting_name.to_sym }
|
|
&.first
|
|
end
|
|
end
|