Files
discourse/lib/upcoming_changes.rb
T
Martin BrennanandLoïc Guitaut b8672b3dfb DEV: Move upcoming change tracking and promotion out of initializers (#37283)
There are several problems with running these upcoming change
checks added in
https://github.com/discourse/discourse/commit/fb9bb3198384b76af9947ad011dda86885e6c536:

* The app is slower to boot because there is DB work to be done
* We sometimes swap out database configs at runtime in our hosting,
  which does not call the initializer again
* We have to think about readonly states for the DB
* There are race conditions because of blue-green deployments
  to consider

To solve these problems, we move the tracking and promotion into
a scheduled job that runs every twenty minutes, since the notification
of admins about upcoming changes are not time-sensitive. This avoids
a whole host of problems.

In addition, we are making upcoming change settings declarative
via SiteSettingExtension. Now, instead of actually changing the DB
value of the change when promoting, we just return true for the
setting if it has met the promotion status. However, if admins have
changed the value of the setting in the DB, we do still use that
value instead. Finally we fall back to the default value of the setting.

I've also added a change to track modified site settings. Before this we
had no way to tell if a setting in `current` inside
`SiteSettingExtension`
was modified by an admin or not. This change tracks modified settings in
a new
`modified` object and their values.

---------

Co-authored-by: Loïc Guitaut <loic@discourse.org>
2026-01-30 12:50:39 +10:00

185 lines
5.9 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
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 = SiteSetting.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)
width, height = nil, nil
File.open(File.join(Rails.public_path, image_path(change_setting_name)), "rb") do |file|
image_info = FastImage.new(file)
width, height = image_info.size
end
{ url: "#{Discourse.base_url}/#{image_path(change_setting_name)}", width:, height: }
end
def self.change_metadata(change_setting_name)
change_setting_name = change_setting_name.to_sym
SiteSetting.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.resolved_value(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 SiteSetting.modified.key?(change_setting_name) &&
UpcomingChanges.change_status(change_setting_name) != :permanent
SiteSetting.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,
SiteSetting.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.
SiteSetting.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
SiteSetting.site_setting_group_ids[change_setting_name].presence || []
end
def self.enabled_for_user?(change_setting_name, user)
change_setting_name = change_setting_name.to_sym
setting_enabled = SiteSetting.public_send(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
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
SiteSetting.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: SiteSetting.humanized_name(name),
description: SiteSetting.description(name),
enabled:,
specific_groups:,
reason:,
}
end
end
end