discourse/app/models/user_history.rb
Martin Brennan d4ac43e605
FEATURE: Upcoming changes part 1 (#34617)
This PR introduces part one of the "Upcoming changes" interface for
Discourse admins.

The upcoming changes feature is an enhancement around our existing
site-setting based feature flagging and experiments system. With some
light metadata, we can give admins a much better overview of the current
work we are doing, with ways for them to opt-out in early stages and
opt-in to things that we haven’t yet turned on by default for them.

This system, along with encouraging a more liberal use of site setting
flags for features, experiments, and refactors in the app, should
minimise the problem of breakages and disruptions for all Discourse
users. It is also our intent with this system for it to be easier for
designers to add and remove these changes.

Finally, it also gives us a kind of running changelog that we can use to
communicate with site owners before releases and “What’s new?” updates.

### FOR REVIEWERS

This initial PR is gated behind a hidden `enable_upcoming_changes` site
setting, because there is still more work to do before we reveal this to
admins.

To test the UI, you can add this metadata under any boolean-based site
setting, though upcoming change settings will specifically be hidden:

```
upcoming_change:
   status: "alpha" (see UpcomingChanges.statuses.keys)
   impact: "feature,staff" (feature|other for the first part, staff|admins|moderators|all_members|developers for the second part)
   learn_more_url: "https://some.url"
```

To test the images, add an image under `public/images/upcoming_changes`
with the file name as `SITE_SETTING_NAME.png`

### Interface

Admins can see the following in the interface for upcoming changes:

* The status of the change. Changes can progress along these statuses:
   * Pre-Alpha
   * Alpha
   * Beta
   * Stable
   * Permanent
* The impact of the change. This is split into Type and Role. Type can
be "Feature" or "Other" for now. Changes may affect the following roles:
   * Admins
   * Moderators
   * Staff
   * All members
* The plugin that is making the change
* The groups that are opted-in to the change. Admins can control these
groups for a gradual rollout. If a change is enabled, it is limited to
these groups.
* In some cases, an image related to the change, behind the "Preview"
link
* A link to learn more about the change

Admins can filter the changes by name, description, plugin, status,
impact type, and whether the change is enabled.

### Promotion system

For our hosted customers, we intend to have a status-based
auto-promotion system as changes progress.

For all sites, once a change reaches the Stable status, if an admin
opts-out of that change it will generate an admin problem message that
will be shown on the dashboard.

For self-hosted Discourse admins, changes will only be forcibly enabled
when they reach the Permanent state.

### Notification system

A notification system for upcoming changes so admins can stay informed
will be added in a followup PR.

---------

Co-authored-by: awesomerobot <kris.aubuchon@discourse.org>
2025-10-30 10:46:14 +10:00

401 lines
12 KiB
Ruby
Vendored

# frozen_string_literal: true
# UserHistory stores information about actions that users have taken,
# like deleting users, changing site settings, dismissing notifications, etc.
# Use other classes, like StaffActionLogger, to log records to this table.
class UserHistory < ActiveRecord::Base
belongs_to :acting_user, class_name: "User"
belongs_to :target_user, class_name: "User"
belongs_to :post
belongs_to :topic
belongs_to :category
# Each value in the context should be shorter than this
MAX_CONTEXT_LENGTH = 50_000
# We often store multiple values in details, particularly during post edits
# Let's allow space for 2 values + a little extra for padding
MAX_DETAILS_LENGTH = 110_000
MAX_JSON_LENGTH = 300_000
validates :details, length: { maximum: MAX_DETAILS_LENGTH }
validates :context, length: { maximum: MAX_CONTEXT_LENGTH }
validates :subject, length: { maximum: MAX_CONTEXT_LENGTH }
validates :ip_address, length: { maximum: MAX_CONTEXT_LENGTH }
validates :email, length: { maximum: MAX_CONTEXT_LENGTH }
validates :previous_value, length: { maximum: MAX_JSON_LENGTH }
validates :new_value, length: { maximum: MAX_JSON_LENGTH }
validates :action, presence: true
scope :only_staff_actions, -> { where("action IN (?)", UserHistory.staff_action_ids) }
before_save :set_admin_only
def self.actions
@actions ||=
Enum.new(
delete_user: 1,
change_trust_level: 2,
change_site_setting: 3,
change_theme: 4,
delete_theme: 5,
checked_for_custom_avatar: 6, # not used anymore
notified_about_avatar: 7,
notified_about_sequential_replies: 8,
notified_about_dominating_topic: 9,
suspend_user: 10,
unsuspend_user: 11,
facebook_no_email: 12, # not used anymore
grant_badge: 13,
revoke_badge: 14,
auto_trust_level_change: 15,
check_email: 16,
delete_post: 17,
delete_topic: 18,
impersonate: 19,
roll_up: 20,
change_username: 21,
custom: 22,
custom_staff: 23,
anonymize_user: 24,
reviewed_post: 25,
change_category_settings: 26,
delete_category: 27,
create_category: 28,
change_site_text: 29,
silence_user: 30,
unsilence_user: 31,
grant_admin: 32,
revoke_admin: 33,
grant_moderation: 34,
revoke_moderation: 35,
backup_create: 36,
rate_limited_like: 37, # not used anymore
revoke_email: 38,
deactivate_user: 39,
wizard_step: 40,
lock_trust_level: 41,
unlock_trust_level: 42,
activate_user: 43,
change_readonly_mode: 44,
backup_download: 45,
backup_destroy: 46,
notified_about_get_a_room: 47,
change_name: 48,
post_locked: 49,
post_unlocked: 50,
check_personal_message: 51,
disabled_second_factor: 52,
post_edit: 53,
topic_published: 54,
recover_topic: 55,
post_approved: 56,
create_badge: 57,
change_badge: 58,
delete_badge: 59,
removed_silence_user: 60,
removed_suspend_user: 61,
removed_unsilence_user: 62,
removed_unsuspend_user: 63,
post_rejected: 64,
merge_user: 65,
entity_export: 66,
change_password: 67,
topic_timestamps_changed: 68,
approve_user: 69,
web_hook_create: 70,
web_hook_update: 71,
web_hook_destroy: 72,
embeddable_host_create: 73,
embeddable_host_update: 74,
embeddable_host_destroy: 75,
web_hook_deactivate: 76,
change_theme_setting: 77,
disable_theme_component: 78,
enable_theme_component: 79,
api_key_create: 80,
api_key_update: 81,
api_key_destroy: 82,
revoke_title: 83,
change_title: 84,
override_upload_secure_status: 85,
page_published: 86,
page_unpublished: 87,
add_email: 88,
update_email: 89,
destroy_email: 90,
topic_closed: 91,
topic_opened: 92,
topic_archived: 93,
topic_unarchived: 94,
post_staff_note_create: 95,
post_staff_note_destroy: 96,
watched_word_create: 97,
watched_word_destroy: 98,
delete_group: 99,
permanently_delete_post_revisions: 100,
create_public_sidebar_section: 101,
update_public_sidebar_section: 102,
destroy_public_sidebar_section: 103,
reset_bounce_score: 104,
create_watched_word_group: 105,
update_watched_word_group: 106,
delete_watched_word_group: 107,
redirected_to_required_fields: 108,
filled_in_required_fields: 109,
topic_slow_mode_set: 110,
topic_slow_mode_removed: 111,
custom_emoji_create: 112,
custom_emoji_destroy: 113,
delete_post_permanently: 114,
delete_topic_permanently: 115,
tag_group_create: 116,
tag_group_destroy: 117,
tag_group_change: 118,
delete_associated_accounts: 119,
change_theme_site_setting: 120,
stop_impersonating: 121,
upcoming_change_toggled: 122,
change_site_setting_groups: 123,
)
end
# Staff actions is a subset of all actions, used to audit actions taken by staff users.
def self.staff_actions
@staff_actions ||= %i[
delete_user
change_trust_level
change_site_setting
change_theme
delete_theme
change_site_text
suspend_user
unsuspend_user
removed_suspend_user
removed_unsuspend_user
grant_badge
revoke_badge
check_email
delete_post
delete_topic
impersonate
roll_up
change_username
custom_staff
anonymize_user
reviewed_post
change_category_settings
delete_category
create_category
silence_user
unsilence_user
removed_silence_user
removed_unsilence_user
grant_admin
revoke_admin
grant_moderation
revoke_moderation
backup_create
revoke_email
deactivate_user
lock_trust_level
unlock_trust_level
activate_user
change_readonly_mode
backup_download
backup_destroy
post_locked
post_unlocked
check_personal_message
disabled_second_factor
post_edit
topic_published
recover_topic
post_approved
create_badge
change_badge
delete_badge
post_rejected
merge_user
entity_export
change_name
topic_timestamps_changed
approve_user
web_hook_create
web_hook_update
web_hook_destroy
web_hook_deactivate
embeddable_host_create
embeddable_host_update
embeddable_host_destroy
change_theme_setting
disable_theme_component
enable_theme_component
revoke_title
change_title
api_key_create
api_key_update
api_key_destroy
override_upload_secure_status
page_published
page_unpublished
add_email
update_email
destroy_email
topic_closed
topic_opened
topic_archived
topic_unarchived
post_staff_note_create
post_staff_note_destroy
watched_word_create
watched_word_destroy
delete_group
permanently_delete_post_revisions
create_public_sidebar_section
update_public_sidebar_section
destroy_public_sidebar_section
reset_bounce_score
update_directory_columns
deleted_unused_tags
renamed_tag
deleted_tag
chat_channel_status_change
chat_auto_remove_membership
create_watched_word_group
update_watched_word_group
delete_watched_word_group
topic_slow_mode_set
topic_slow_mode_removed
custom_emoji_create
custom_emoji_destroy
delete_post_permanently
delete_topic_permanently
tag_group_create
tag_group_destroy
tag_group_change
delete_associated_accounts
toggle_flag
delete_flag
update_flag
create_flag
change_theme_site_setting
stop_impersonating
upcoming_change_toggled
change_site_setting_groups
]
end
def self.staff_action_ids
@staff_action_ids ||= staff_actions.map { |a| actions[a] }
end
def self.admin_only_action_ids
@admin_only_action_ids ||= [actions[:change_site_setting]]
end
def self.with_filters(filters)
query = self
query = query.where(action: filters[:action_id]) if filters[:action_id].present?
query = query.where(custom_type: filters[:custom_type]) if filters[:custom_type].present?
%i[acting_user target_user].each do |key|
if filters[key] && (obj_id = User.where(username_lower: filters[key].downcase).pluck(:id))
query = query.where("#{key}_id = ?", obj_id)
end
end
query = query.where("subject = ?", filters[:subject]) if filters[:subject]
query
end
def self.for(user, action_type)
self.where(target_user_id: user.id, action: UserHistory.actions[action_type])
end
def self.exists_for_user?(user, action_type, opts = nil)
opts = opts || {}
result = self.where(target_user_id: user.id, action: UserHistory.actions[action_type])
result = result.where(topic_id: opts[:topic_id]) if opts[:topic_id]
result.exists?
end
def self.staff_filters
%i[action_id custom_type acting_user target_user subject action_name]
end
def self.staff_action_records(viewer, opts = nil)
opts ||= {}
custom_staff = opts[:action_id].to_i == actions[:custom_staff]
if custom_staff
opts[:custom_type] = opts[:action_name]
else
opts[:action_id] = self.actions[opts[:action_name].to_sym] if opts[:action_name]
end
query =
self
.with_filters(opts.slice(*staff_filters))
.only_staff_actions
.order("id DESC")
.includes(:acting_user, :target_user)
query = query.where(admin_only: false) unless viewer && viewer.admin?
query = query.where("created_at >= ?", opts[:start_date].to_time) if opts[:start_date]
query = query.where("created_at <= ?", opts[:end_date].to_time) if opts[:end_date]
query
end
def set_admin_only
self.admin_only = UserHistory.admin_only_action_ids.include?(self.action)
self
end
def new_value_is_json?
%i[change_theme delete_theme tag_group_create tag_group_destroy tag_group_change]
.map { |i| UserHistory.actions[i] }
.include?(action)
end
def previous_value_is_json?
new_value_is_json?
end
end
# == Schema Information
#
# Table name: user_histories
#
# id :integer not null, primary key
# action :integer not null
# acting_user_id :integer
# target_user_id :integer
# details :text
# created_at :datetime not null
# updated_at :datetime not null
# context :string
# ip_address :string
# email :string
# subject :text
# previous_value :text
# new_value :text
# topic_id :integer
# admin_only :boolean default(FALSE)
# post_id :integer
# custom_type :string
# category_id :integer
#
# Indexes
#
# index_user_histories_on_acting_user_id_and_action_and_id (acting_user_id,action,id)
# index_user_histories_on_action_and_id (action,id)
# index_user_histories_on_category_id (category_id)
# index_user_histories_on_post_id (post_id)
# index_user_histories_on_subject_and_id (subject,id)
# index_user_histories_on_target_user_id_and_id (target_user_id,id)
# index_user_histories_on_topic_id_and_target_user_id_and_action (topic_id,target_user_id,action)
#