FEATURE: Gracefully handle unhandled reviewables (#31118)

Plugins like for example AI or Akismet create reviewable items. When the
plugin is disabled, then we cannot properly handle those items.

In that situation, we should display warnings about unhandled types.
Instruct admin to reenable plugins. In addition, we should allow the
admin to delete all pending reviews from disabled plugins.
This commit is contained in:
Krzysztof Kotlarek 2025-02-05 14:38:45 +11:00 committed by GitHub
parent f439bf14cc
commit 5eb7d6d9c0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 179 additions and 1 deletions

View File

@ -1,12 +1,18 @@
import Controller from "@ember/controller"; import Controller from "@ember/controller";
import { action } from "@ember/object"; import { action } from "@ember/object";
import { next } from "@ember/runloop"; import { next } from "@ember/runloop";
import { service } from "@ember/service";
import { underscore } from "@ember/string"; import { underscore } from "@ember/string";
import { isPresent } from "@ember/utils"; import { isPresent } from "@ember/utils";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import discourseComputed from "discourse/lib/decorators"; import discourseComputed from "discourse/lib/decorators";
import { i18n } from "discourse-i18n"; import { i18n } from "discourse-i18n";
export default class ReviewIndexController extends Controller { export default class ReviewIndexController extends Controller {
@service currentUser;
@service dialog;
@service toasts;
queryParams = [ queryParams = [
"priority", "priority",
"type", "type",
@ -106,6 +112,11 @@ export default class ReviewIndexController extends Controller {
next(() => this.send("refreshRoute")); next(() => this.send("refreshRoute"));
} }
@discourseComputed("unknownReviewableTypes")
displayUnknownReviewableTypesWarning(unknownReviewableTypes) {
return unknownReviewableTypes?.length > 0 && this.currentUser.admin;
}
@action @action
remove(ids) { remove(ids) {
if (!ids) { if (!ids) {
@ -129,6 +140,26 @@ export default class ReviewIndexController extends Controller {
this.refreshModel(); this.refreshModel();
} }
@action
ignoreAllUnknownTypes() {
return this.dialog.deleteConfirm({
message: i18n("review.unknown.delete_confirm"),
didConfirm: async () => {
try {
await ajax("/admin/unknown_reviewables/destroy", {
type: "delete",
});
this.set("unknownReviewableTypes", []);
this.toasts.success({
data: { message: i18n("review.unknown.ignore_success") },
});
} catch (e) {
popupAjaxError(e);
}
},
});
}
@action @action
refresh() { refresh() {
const currentStatus = this.status; const currentStatus = this.status;

View File

@ -39,6 +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,
scoreTypes: meta.score_types, scoreTypes: meta.score_types,
filterUsername: meta.username, filterUsername: meta.username,
filterReviewedBy: meta.reviewed_by, filterReviewedBy: meta.reviewed_by,

View File

@ -1,3 +1,34 @@
{{#if this.displayUnknownReviewableTypesWarning}}
<div class="alert alert-info unknown-reviewables">
<span class="text">{{i18n
"review.unknown.title"
count=this.unknownReviewableTypes.length
}}</span>
<ul>
{{#each this.unknownReviewableTypes as |type|}}
<li>{{type}}</li>
{{/each}}
</ul>
<span class="text">{{htmlSafe
(i18n
"review.unknown.instruction" url="https://meta.discourse.org/t/350179"
)
}}</span>
<div class="unknown-reviewables__options">
<LinkTo @route="adminPlugins.index" class="btn">
{{d-icon "puzzle-piece"}}
<span>{{i18n "review.unknown.enable_plugins"}}</span>
</LinkTo>
<DButton
@label="review.unknown.ignore_all"
@icon="trash-can"
@action={{this.ignoreAllUnknownTypes}}
class="btn-default"
/>
</div>
</div>
{{/if}}
<div class="reviewable-container"> <div class="reviewable-container">
<div class="reviewable-list"> <div class="reviewable-list">
{{#if this.reviewables}} {{#if this.reviewables}}

View File

@ -639,3 +639,7 @@
margin-bottom: 0.5em; margin-bottom: 0.5em;
} }
} }
.unknown-reviewables__options {
margin-top: 1em;
}

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
class Admin::UnknownReviewablesController < Admin::AdminController
def destroy
Reviewable.destroy_unknown_types!
render json: success_json
end
end

View File

@ -74,6 +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,
score_types: score_types:
ReviewableScore ReviewableScore
.types .types

View File

@ -73,6 +73,10 @@ class Reviewable < ActiveRecord::Base
[ReviewableFlaggedPost, ReviewableQueuedPost, ReviewableUser, ReviewablePost] [ReviewableFlaggedPost, ReviewableQueuedPost, ReviewableUser, ReviewablePost]
end end
def self.sti_names
self.types.map(&:sti_name)
end
def self.custom_filters def self.custom_filters
@reviewable_filters ||= [] @reviewable_filters ||= []
end end
@ -550,6 +554,7 @@ class Reviewable < ActiveRecord::Base
"LEFT JOIN reviewable_claimed_topics rct ON reviewables.topic_id = rct.topic_id", "LEFT JOIN reviewable_claimed_topics rct ON reviewables.topic_id = rct.topic_id",
).where("rct.user_id IS NULL OR rct.user_id = ?", user.id) ).where("rct.user_id IS NULL OR rct.user_id = ?", user.id)
end end
result = result.where(type: Reviewable.sti_names)
result = result.limit(limit) if limit result = result.limit(limit) if limit
result = result.offset(offset) if offset result = result.offset(offset) if offset
result result
@ -761,6 +766,14 @@ class Reviewable < ActiveRecord::Base
) )
end end
def self.unknown_types
Reviewable.pending.distinct.pluck(:type) - Reviewable.sti_names
end
def self.destroy_unknown_types!
Reviewable.pending.where.not(type: Reviewable.sti_names).delete_all
end
private private
def update_flag_stats(status:, user_ids:) def update_flag_stats(status:, user_ids:)

View File

@ -584,6 +584,15 @@ en:
date_filter: "Posted between" date_filter: "Posted between"
in_reply_to: "in reply to" in_reply_to: "in reply to"
filtered_flagged_by: "Flagged by" filtered_flagged_by: "Flagged by"
unknown:
title:
one: "You have pending reviewables from disabled plugin:"
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>"
ignore_all: "Ignore all"
enable_plugins: "Enable plugins"
delete_confirm: "Are you sure you want to delete all reviews created by disabled plugins?"
ignore_success: "All reviews created by disabled plugins have been deleted."
explain: explain:
why: "explain why this item ended up in the queue" why: "explain why this item ended up in the queue"
title: "Reviewable Scoring" title: "Reviewable Scoring"

View File

@ -449,6 +449,8 @@ Discourse::Application.routes.draw do
get "section/:section_id" => "section#show", :constraints => AdminConstraint.new get "section/:section_id" => "section#show", :constraints => AdminConstraint.new
resources :admin_notices, only: %i[destroy], constraints: AdminConstraint.new resources :admin_notices, only: %i[destroy], constraints: AdminConstraint.new
delete "unknown_reviewables/destroy" => "unknown_reviewables#destroy"
end # admin namespace end # admin namespace
get "email/unsubscribe/:key" => "email#unsubscribe", :as => "email_unsubscribe" get "email/unsubscribe/:key" => "email#unsubscribe", :as => "email_unsubscribe"

View File

@ -188,13 +188,20 @@ RSpec.describe Reviewable, type: :model do
expect(reviewables).to contain_exactly(reviewable) expect(reviewables).to contain_exactly(reviewable)
end end
it "Does not filter by status when status parameter is set to all" do it "does not filter by status when status parameter is set to all" do
rejected_reviewable = rejected_reviewable =
Fabricate(:reviewable, target: post, status: Reviewable.statuses[:rejected]) Fabricate(:reviewable, target: post, status: Reviewable.statuses[:rejected])
reviewables = Reviewable.list_for(user, status: :all) reviewables = Reviewable.list_for(user, status: :all)
expect(reviewables).to match_array [reviewable, rejected_reviewable] expect(reviewables).to match_array [reviewable, rejected_reviewable]
end end
it "does not include reviewables of unknown type" do
unknown_reviewable = Fabricate(:reviewable, target: post)
unknown_reviewable.update_column(:type, "UnknownPlugin")
reviewables = Reviewable.list_for(user, status: :all)
expect(reviewables).to match_array [reviewable]
end
it "supports sorting" do it "supports sorting" do
r0 = Fabricate(:reviewable, score: 100, created_at: 3.months.ago) r0 = Fabricate(:reviewable, score: 100, created_at: 3.months.ago)
r1 = Fabricate(:reviewable, score: 999, created_at: 1.month.ago) r1 = Fabricate(:reviewable, score: 999, created_at: 1.month.ago)

View File

@ -0,0 +1,37 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Admin::UnknownReviewablesController do
let(:admin) { Fabricate(:admin) }
let(:user) { Fabricate(:user) }
fab!(:reviewable)
fab!(:unknown_reviewable) { Fabricate(:reviewable, type: "ReviewablePost") }
describe "#destroy" do
context "when user is an admin" do
before do
sign_in admin
allow(Reviewable).to receive(:types).and_return([ReviewableUser])
end
it "destroys all pending reviewables of specified types" do
delete "/admin/unknown_reviewables/destroy.json"
expect(response.code).to eq("200")
reviewable.reload
expect { unknown_reviewable.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
context "when user is not an admin" do
before { sign_in user }
it "raises Discourse::InvalidAccess" do
delete "/admin/unknown_reviewables/destroy.json"
expect(response.code).to eq("404")
end
end
end
end

View File

@ -79,6 +79,19 @@ module PageObjects
page.has_no_css?("dialog-container .dialog-content") page.has_no_css?("dialog-container .dialog-content")
end end
def click_ignore_all_unknown_reviewables
find(".unknown-reviewables__options button").click
find(".dialog-footer .btn-danger").click
end
def has_information_about_unknown_reviewables_visible?
page.has_css?(".unknown-reviewables")
end
def has_no_information_about_unknown_reviewables_visible?
page.has_no_css?(".unknown-reviewables")
end
private private
def reviewable_action_dropdown def reviewable_action_dropdown

View File

@ -7,6 +7,7 @@ describe "Reviewables", type: :system do
fab!(:long_post) { Fabricate(:post_with_very_long_raw_content) } fab!(:long_post) { Fabricate(:post_with_very_long_raw_content) }
fab!(:post) fab!(:post)
let(:composer) { PageObjects::Components::Composer.new } let(:composer) { PageObjects::Components::Composer.new }
let(:moderator) { Fabricate(:moderator) }
before { sign_in(admin) } before { sign_in(admin) }
@ -212,4 +213,24 @@ describe "Reviewables", type: :system do
end end
end end
end end
describe "when there is an unknown plugin reviewable" do
fab!(:reviewable) { Fabricate(:reviewable_flagged_post, target: long_post) }
before { reviewable.update_column(:type, "UnknownPlugin") }
it "informs admin and allows to delete them" do
visit("/review")
expect(review_page).to have_information_about_unknown_reviewables_visible
review_page.click_ignore_all_unknown_reviewables
expect(review_page).to have_no_information_about_unknown_reviewables_visible
end
it "does not inform moderator about them" do
sign_in(moderator)
visit("/review")
expect(review_page).to have_no_information_about_unknown_reviewables_visible
end
end
end end