DEV: Add a new type_source field to the Reviewable model. (#31325)

This change adds a new `type_source` field to the `Reviewable` model, indicating whether the Reviewable type was registered by `core`, a plugin, or an `unknown` source.

When a plugin that registered a Reviewable type is disabled, this allows us to tell the user which plugin they need to re-enable to handle any orphan reviewable items.
This commit is contained in:
Gary Pendergast
2025-02-20 09:09:47 +11:00
committed by GitHub
parent dd5901d51e
commit 29a8c6ee49
22 changed files with 232 additions and 8 deletions

View File

@ -6,6 +6,7 @@ import { underscore } from "@ember/string";
import { isPresent } from "@ember/utils"; import { isPresent } from "@ember/utils";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error"; import { popupAjaxError } from "discourse/lib/ajax-error";
import { REVIEWABLE_UNKNOWN_TYPE_SOURCE } from "discourse/lib/constants";
import discourseComputed from "discourse/lib/decorators"; import discourseComputed from "discourse/lib/decorators";
import { i18n } from "discourse-i18n"; import { i18n } from "discourse-i18n";
@ -44,6 +45,7 @@ export default class ReviewIndexController extends Controller {
sort_order = null; sort_order = null;
additional_filters = null; additional_filters = null;
filterScoreType = null; filterScoreType = null;
unknownTypeSource = REVIEWABLE_UNKNOWN_TYPE_SOURCE;
@discourseComputed("reviewableTypes") @discourseComputed("reviewableTypes")
allTypes() { allTypes() {

View File

@ -117,3 +117,5 @@ export const REPORT_MODES = {
inline_table: "inline_table", inline_table: "inline_table",
storage_stats: "storage_stats", storage_stats: "storage_stats",
}; };
export const REVIEWABLE_UNKNOWN_TYPE_SOURCE = "unknown";

View File

@ -39,7 +39,7 @@ export default class ReviewIndex extends DiscourseRoute {
filterCategoryId: meta.category_id, filterCategoryId: meta.category_id,
filterPriority: meta.priority, filterPriority: meta.priority,
reviewableTypes: meta.reviewable_types, reviewableTypes: meta.reviewable_types,
unknownReviewableTypes: meta.unknown_reviewable_types, unknownReviewableTypes: meta.unknown_reviewable_types_and_sources,
scoreTypes: meta.score_types, scoreTypes: meta.score_types,
filterUsername: meta.username, filterUsername: meta.username,
filterReviewedBy: meta.reviewed_by, filterReviewedBy: meta.reviewed_by,

View File

@ -6,8 +6,19 @@
}}</span> }}</span>
<ul> <ul>
{{#each this.unknownReviewableTypes as |type|}} {{#each this.unknownReviewableTypes as |reviewable|}}
<li>{{type}}</li> {{#if (eq reviewable.source this.unknownTypeSource)}}
<li>{{i18n
"review.unknown.reviewable_unknown_source"
reviewableType=reviewable.type
}}</li>
{{else}}
<li>{{i18n
"review.unknown.reviewable_known_source"
reviewableType=reviewable.type
pluginName=reviewable.source
}}</li>
{{/if}}
{{/each}} {{/each}}
</ul> </ul>
<span class="text">{{htmlSafe <span class="text">{{htmlSafe

View File

@ -42,6 +42,7 @@ export const NOTIFICATION_TYPES = {
new_features: 37, new_features: 37,
admin_problems: 38, admin_problems: 38,
linked_consolidated: 39, linked_consolidated: 39,
chat_watched_thread: 40,
following: 800, following: 800,
following_created_topic: 801, following_created_topic: 801,
following_replied: 802, following_replied: 802,

View File

@ -74,7 +74,7 @@ class ReviewablesController < ApplicationController
total_rows_reviewables: total_rows, total_rows_reviewables: total_rows,
types: meta_types, types: meta_types,
reviewable_types: Reviewable.types, reviewable_types: Reviewable.types,
unknown_reviewable_types: Reviewable.unknown_types, unknown_reviewable_types_and_sources: Reviewable.unknown_types_and_sources,
score_types: score_types:
ReviewableScore ReviewableScore
.types .types

View File

@ -7,6 +7,8 @@ class Reviewable < ActiveRecord::Base
ReviewableUser: BasicReviewableUserSerializer, ReviewableUser: BasicReviewableUserSerializer,
} }
UNKNOWN_TYPE_SOURCE = "unknown"
self.ignored_columns = [:reviewable_by_group_id] self.ignored_columns = [:reviewable_by_group_id]
class UpdateConflict < StandardError class UpdateConflict < StandardError
@ -42,6 +44,8 @@ class Reviewable < ActiveRecord::Base
validates :reject_reason, length: { maximum: 2000 } validates :reject_reason, length: { maximum: 2000 }
before_save :set_type_source
after_create { log_history(:created, created_by) } after_create { log_history(:created, created_by) }
after_commit(on: :create) { DiscourseEvent.trigger(:reviewable_created, self) } after_commit(on: :create) { DiscourseEvent.trigger(:reviewable_created, self) }
@ -77,6 +81,16 @@ class Reviewable < ActiveRecord::Base
self.types.map(&:sti_name) self.types.map(&:sti_name)
end end
def self.source_for(type)
type = type.sti_name if type.is_a?(Class)
return UNKNOWN_TYPE_SOURCE if Reviewable.sti_names.exclude?(type)
DiscoursePluginRegistry
.reviewable_types_lookup
.find { |r| r[:klass].sti_name == type }
&.dig(:plugin) || "core"
end
def self.custom_filters def self.custom_filters
@reviewable_filters ||= [] @reviewable_filters ||= []
end end
@ -89,6 +103,10 @@ class Reviewable < ActiveRecord::Base
@reviewable_filters = [] @reviewable_filters = []
end end
def set_type_source
self.type_source = Reviewable.source_for(type)
end
def created_new! def created_new!
self.created_new = true self.created_new = true
self.topic = target.topic if topic.blank? && target.is_a?(Post) self.topic = target.topic if topic.blank? && target.is_a?(Post)
@ -766,8 +784,14 @@ class Reviewable < ActiveRecord::Base
) )
end end
def self.unknown_types def self.unknown_types_and_sources
Reviewable.pending.distinct.pluck(:type) - Reviewable.sti_names @known_sources ||= Reviewable.sti_names.map { |n| [n, Reviewable.source_for(n)] }
known_unknowns = Reviewable.pending.distinct.pluck(:type, :type_source) - @known_sources
known_unknowns
.map { |type, source| { type: type, source: source } }
.sort_by { |e| [e[:source] == UNKNOWN_TYPE_SOURCE ? 1 : 0, e[:source], e[:type]] }
end end
def self.destroy_unknown_types! def self.destroy_unknown_types!
@ -804,6 +828,7 @@ end
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# type :string not null # type :string not null
# type_source :string default("unknown"), not null
# status :integer default("pending"), not null # status :integer default("pending"), not null
# created_by_id :integer not null # created_by_id :integer not null
# reviewable_by_moderator :boolean default(FALSE), not null # reviewable_by_moderator :boolean default(FALSE), not null

View File

@ -399,6 +399,7 @@ end
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# type :string not null # type :string not null
# type_source :string default("unknown"), not null
# status :integer default("pending"), not null # status :integer default("pending"), not null
# created_by_id :integer not null # created_by_id :integer not null
# reviewable_by_moderator :boolean default(FALSE), not null # reviewable_by_moderator :boolean default(FALSE), not null

View File

@ -142,6 +142,7 @@ end
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# type :string not null # type :string not null
# type_source :string default("unknown"), not null
# status :integer default("pending"), not null # status :integer default("pending"), not null
# created_by_id :integer not null # created_by_id :integer not null
# reviewable_by_moderator :boolean default(FALSE), not null # reviewable_by_moderator :boolean default(FALSE), not null

View File

@ -240,6 +240,7 @@ end
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# type :string not null # type :string not null
# type_source :string default("unknown"), not null
# status :integer default("pending"), not null # status :integer default("pending"), not null
# created_by_id :integer not null # created_by_id :integer not null
# reviewable_by_moderator :boolean default(FALSE), not null # reviewable_by_moderator :boolean default(FALSE), not null

View File

@ -102,6 +102,7 @@ end
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# type :string not null # type :string not null
# type_source :string default("unknown"), not null
# status :integer default("pending"), not null # status :integer default("pending"), not null
# created_by_id :integer not null # created_by_id :integer not null
# reviewable_by_moderator :boolean default(FALSE), not null # reviewable_by_moderator :boolean default(FALSE), not null

View File

@ -6,6 +6,7 @@ class ReviewableSerializer < ApplicationSerializer
attributes( attributes(
:id, :id,
:type, :type,
:type_source,
:topic_id, :topic_id,
:topic_url, :topic_url,
:target_url, :target_url,

View File

@ -589,6 +589,8 @@ en:
one: "You have pending reviewables from disabled plugin:" one: "You have pending reviewables from disabled plugin:"
other: "You have pending reviewables from disabled plugins:" other: "You have pending reviewables from disabled plugins:"
instruction: "They cannot be properly displayed until you enable the relevant plugin. Please enable the plugin and refresh the page. Alternatively, you can ignore them. <a href='%{url}' target='_blank'>Learn more...</a>" instruction: "They cannot be properly displayed until you enable the relevant plugin. Please enable the plugin and refresh the page. Alternatively, you can ignore them. <a href='%{url}' target='_blank'>Learn more...</a>"
reviewable_unknown_source: "%{reviewableType} (unknown plugin)"
reviewable_known_source: "%{reviewableType} (from the '%{pluginName}' plugin)"
ignore_all: "Ignore all" ignore_all: "Ignore all"
enable_plugins: "Enable plugins" enable_plugins: "Enable plugins"
delete_confirm: "Are you sure you want to delete all reviews created by disabled plugins?" delete_confirm: "Are you sure you want to delete all reviews created by disabled plugins?"

View File

@ -0,0 +1,27 @@
# frozen_string_literal: true
class AddTypeSourceToReviewable < ActiveRecord::Migration[7.2]
def change
add_column :reviewables, :type_source, :string, null: false, default: "unknown"
# Migrate known reviewables to have a type_source
known_reviewables = {
"chat" => %w[ReviewableChatMessage],
"core" => %w[ReviewableFlaggedPost ReviewableQueuedPost ReviewableUser ReviewablePost],
"discourse-ai" => %w[ReviewableAiChatMessage ReviewableAiPost],
"discourse-akismet" => %w[
ReviewableAkismetPost
ReviewableAkismetPostVotingComment
ReviewableAkismetUser
],
"discourse-antivirus" => %w[ReviewableUpload],
"discourse-category-experts" => %w[ReviewableCategoryExpertSuggestion],
"discourse-post-voting" => %w[ReviewablePostVotingComment],
}
known_reviewables.each do |plugin, types|
types.each do |type|
Reviewable.where(type: type, type_source: "unknown").update_all(type_source: plugin)
end
end
end
end

View File

@ -50,6 +50,8 @@ class DiscoursePluginRegistry
define_singleton_method("register_#{register_name.to_s.singularize}") do |value, plugin| define_singleton_method("register_#{register_name.to_s.singularize}") do |value, plugin|
public_send(:"_raw_#{register_name}") << { plugin: plugin, value: value } public_send(:"_raw_#{register_name}") << { plugin: plugin, value: value }
end end
yield(self) if block_given?
end end
define_register :javascripts, Set define_register :javascripts, Set
@ -129,6 +131,14 @@ class DiscoursePluginRegistry
define_filtered_register :custom_filter_mappings define_filtered_register :custom_filter_mappings
define_filtered_register :reviewable_types do |singleton|
singleton.define_singleton_method("reviewable_types_lookup") do
public_send(:"_raw_reviewable_types")
.filter_map { |h| { plugin: h[:plugin].name, klass: h[:value] } if h[:plugin].enabled? }
.uniq
end
end
def self.register_auth_provider(auth_provider) def self.register_auth_provider(auth_provider)
self.auth_providers << auth_provider self.auth_providers << auth_provider
end end

View File

@ -169,6 +169,8 @@ task "javascript:update_constants" => :environment do
export const USER_FIELD_FLAGS = #{UserField::FLAG_ATTRIBUTES}; export const USER_FIELD_FLAGS = #{UserField::FLAG_ATTRIBUTES};
export const REPORT_MODES = #{Report::MODES.to_json}; export const REPORT_MODES = #{Report::MODES.to_json};
export const REVIEWABLE_UNKNOWN_TYPE_SOURCE = "#{Reviewable::UNKNOWN_TYPE_SOURCE}";
JS JS
pretty_notifications = Notification.types.map { |n| " #{n[0]}: #{n[1]}," }.join("\n") pretty_notifications = Notification.types.map { |n| " #{n[0]}: #{n[1]}," }.join("\n")

View File

@ -15,6 +15,7 @@ RSpec.describe DiscoursePluginRegistry do
let(:plugin_class) do let(:plugin_class) do
Class.new(Plugin::Instance) do Class.new(Plugin::Instance) do
attr_accessor :enabled attr_accessor :enabled
attr_accessor :name
def enabled? def enabled?
@enabled @enabled
end end
@ -52,6 +53,20 @@ RSpec.describe DiscoursePluginRegistry do
plugin.enabled = false plugin.enabled = false
expect(fresh_registry.test_things.length).to eq(0) expect(fresh_registry.test_things.length).to eq(0)
end end
it "runs the callback block" do
fresh_registry.define_filtered_register(:test_other_things) do |singleton|
singleton.define_singleton_method(:my_fun_method) { true }
end
fresh_registry.register_test_other_thing("mything", plugin)
plugin.enabled = true
expect(fresh_registry.test_other_things).to contain_exactly("mything")
expect(fresh_registry.methods.include?(:my_fun_method)).to eq(true)
expect(fresh_registry.my_fun_method).to eq(true)
end
end end
end end

View File

@ -625,13 +625,22 @@ TEXT
subject(:register_reviewable_type) { plugin_instance.register_reviewable_type(new_type) } subject(:register_reviewable_type) { plugin_instance.register_reviewable_type(new_type) }
context "when the provided class inherits from `Reviewable`" do context "when the provided class inherits from `Reviewable`" do
let(:new_type) { Class.new(Reviewable) } let(:new_type) do
class MyReviewable < Reviewable
end
MyReviewable
end
it "adds the provided class to the existing types" do it "adds the provided class to the existing types" do
expect { register_reviewable_type }.to change { Reviewable.types.size }.by(1) expect { register_reviewable_type }.to change { Reviewable.types.size }.by(1)
expect(Reviewable.types).to include(new_type) expect(Reviewable.types).to include(new_type)
end end
it "shows the correct source for the new type" do
register_reviewable_type
expect(Reviewable.source_for(new_type)).to eq("discourse-sample-plugin")
end
context "when the plugin is disabled" do context "when the plugin is disabled" do
before do before do
register_reviewable_type register_reviewable_type

View File

@ -341,6 +341,89 @@ RSpec.describe Reviewable, type: :model do
expect(Reviewable.valid_type?("User")).to eq(false) expect(Reviewable.valid_type?("User")).to eq(false)
end end
describe ".source_for" do
it "returns the correct source" do
expect(Reviewable.source_for(ReviewablePost)).to eq("core")
expect(Reviewable.source_for(ReviewableFlaggedPost)).to eq("core")
expect(Reviewable.source_for(ReviewableQueuedPost)).to eq("core")
expect(Reviewable.source_for(ReviewableUser)).to eq("core")
expect(Reviewable.source_for("NonExistentType")).to eq("unknown")
end
end
describe ".unknown_types_and_sources" do
it "returns an empty array when no unknown types are present" do
expect(Reviewable.unknown_types_and_sources).to eq([])
end
context "with reviewables of unknown type or sources" do
fab!(:core_type) do
type = Fabricate(:reviewable)
type.update_columns(type: "ReviewableDoesntExist", type_source: "core")
type
end
fab!(:known_core_type) do
type = Fabricate(:reviewable)
type.update_columns(type: "ReviewableFlaggedPost", type_source: "core")
type
end
fab!(:unknown_type) do
type = Fabricate(:reviewable)
type.update_columns(type: "UnknownType", type_source: "unknown")
type
end
fab!(:plugin_type) do
type = Fabricate(:reviewable)
type.update_columns(type: "PluginReviewableDoesntExist", type_source: "my-plugin")
type
end
fab!(:plugin_type2) do
type = Fabricate(:reviewable)
type.update_columns(type: "PluginReviewableStillDoesntExist", type_source: "my-plugin")
type
end
fab!(:plugin_type3) do
type = Fabricate(:reviewable)
type.update_columns(
type: "AnotherPluginReviewableDoesntExist",
type_source: "another-plugin",
)
type
end
fab!(:plugin_type4) do
type = Fabricate(:reviewable)
type.update_columns(type: "ThisIsGettingSilly", type_source: "zzz-last-plugin")
type
end
fab!(:unknown_type2) do
type = Fabricate(:reviewable)
type.update_columns(type: "AnotherUnknownType", type_source: "unknown")
type
end
it "returns an array of unknown types, sorted by source (with 'unknown' always last), then by type" do
expect(Reviewable.unknown_types_and_sources).to eq(
[
{ type: "AnotherPluginReviewableDoesntExist", source: "another-plugin" },
{ type: "ReviewableDoesntExist", source: "core" },
{ type: "PluginReviewableDoesntExist", source: "my-plugin" },
{ type: "PluginReviewableStillDoesntExist", source: "my-plugin" },
{ type: "ThisIsGettingSilly", source: "zzz-last-plugin" },
{ type: "AnotherUnknownType", source: "unknown" },
{ type: "UnknownType", source: "unknown" },
],
)
end
end
end
describe "events" do describe "events" do
let!(:moderator) { Fabricate(:moderator) } let!(:moderator) { Fabricate(:moderator) }
let(:reviewable) { Fabricate(:reviewable) } let(:reviewable) { Fabricate(:reviewable) }

View File

@ -10,6 +10,7 @@ RSpec.describe ReviewableSerializer do
expect(json[:id]).to eq(reviewable.id) expect(json[:id]).to eq(reviewable.id)
expect(json[:status]).to eq(reviewable.status_for_database) expect(json[:status]).to eq(reviewable.status_for_database)
expect(json[:type]).to eq(reviewable.type) expect(json[:type]).to eq(reviewable.type)
expect(json[:type_source]).to eq(reviewable.type_source)
expect(json[:created_at]).to eq(reviewable.created_at) expect(json[:created_at]).to eq(reviewable.created_at)
expect(json[:category_id]).to eq(reviewable.category_id) expect(json[:category_id]).to eq(reviewable.category_id)
expect(json[:can_edit]).to eq(true) expect(json[:can_edit]).to eq(true)

View File

@ -92,6 +92,26 @@ module PageObjects
page.has_no_css?(".unknown-reviewables") page.has_no_css?(".unknown-reviewables")
end end
def has_listing_for_unknown_reviewables_plugin?(reviewable_type, plugin_name)
page.has_css?(
".unknown-reviewables ul li",
text:
I18n.t(
"js.review.unknown.reviewable_known_source",
reviewableType: reviewable_type,
pluginName: plugin_name,
),
)
end
def has_listing_for_unknown_reviewables_unknown_source?(reviewable_type)
page.has_css?(
".unknown-reviewables ul li",
text:
I18n.t("js.review.unknown.reviewable_unknown_source", reviewableType: reviewable_type),
)
end
private private
def reviewable_action_dropdown def reviewable_action_dropdown

View File

@ -216,12 +216,21 @@ describe "Reviewables", type: :system do
describe "when there is an unknown plugin reviewable" do describe "when there is an unknown plugin reviewable" do
fab!(:reviewable) { Fabricate(:reviewable_flagged_post, target: long_post) } fab!(:reviewable) { Fabricate(:reviewable_flagged_post, target: long_post) }
fab!(:reviewable2) { Fabricate(:reviewable) }
before { reviewable.update_column(:type, "UnknownPlugin") } before do
reviewable.update_columns(type: "UnknownPlugin", type_source: "some-plugin")
reviewable2.update_columns(type: "UnknownSource", type_source: "unknown")
end
it "informs admin and allows to delete them" do it "informs admin and allows to delete them" do
visit("/review") visit("/review")
expect(review_page).to have_information_about_unknown_reviewables_visible expect(review_page).to have_information_about_unknown_reviewables_visible
expect(review_page).to have_listing_for_unknown_reviewables_plugin(
reviewable.type,
reviewable.type_source,
)
expect(review_page).to have_listing_for_unknown_reviewables_unknown_source(reviewable2.type)
review_page.click_ignore_all_unknown_reviewables review_page.click_ignore_all_unknown_reviewables
expect(review_page).to have_no_information_about_unknown_reviewables_visible expect(review_page).to have_no_information_about_unknown_reviewables_visible
end end