discourse/app/controllers/reviewables_controller.rb
Krzysztof Kotlarek 5eb7d6d9c0
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.
2025-02-05 14:38:45 +11:00

337 lines
9.9 KiB
Ruby

# frozen_string_literal: true
class ReviewablesController < ApplicationController
requires_login
PER_PAGE = 10
before_action :version_required, only: %i[update perform]
before_action :ensure_can_see, except: [:destroy]
around_action :with_deleted_content,
only: %i[index show],
if: ->(controller) { controller.guardian.is_staff? }
def index
offset = params[:offset].to_i
if params[:type].present?
raise Discourse::InvalidParameters.new(:type) unless Reviewable.valid_type?(params[:type])
end
status = (params[:status] || "pending").to_sym
raise Discourse::InvalidParameters.new(:status) if allowed_statuses.exclude?(status)
topic_id = params[:topic_id] ? params[:topic_id].to_i : nil
category_id = params[:category_id] ? params[:category_id].to_i : nil
custom_keys = Reviewable.custom_filters.map(&:first)
additional_filters =
JSON.parse(params.fetch(:additional_filters, {}), symbolize_names: true).slice(*custom_keys)
filters = {
ids: params[:ids],
status: status,
category_id: category_id,
topic_id: topic_id,
additional_filters: additional_filters.reject { |_, v| v.blank? },
}
%i[
priority
username
reviewed_by
from_date
to_date
type
sort_order
flagged_by
score_type
].each { |filter_key| filters[filter_key] = params[filter_key] }
total_rows = Reviewable.list_for(current_user, **filters).count
reviewables =
Reviewable.list_for(current_user, **filters.merge(limit: PER_PAGE, offset: offset)).to_a
claimed_topics = ReviewableClaimedTopic.claimed_hash(reviewables.map { |r| r.topic_id }.uniq)
# This is a bit awkward, but ActiveModel serializers doesn't seem to serialize STI. Note `hash`
# is mutated by the serializer and contains the side loaded records which must be merged in the end.
hash = {}
json = {
reviewables:
reviewables.map! do |r|
result =
r
.serializer
.new(r, root: nil, hash: hash, scope: guardian, claimed_topics: claimed_topics)
.as_json
hash[:bundled_actions].uniq!
(hash["actions"] || []).uniq!
result
end,
meta:
filters.merge(
total_rows_reviewables: total_rows,
types: meta_types,
reviewable_types: Reviewable.types,
unknown_reviewable_types: Reviewable.unknown_types,
score_types:
ReviewableScore
.types
.filter { |k, v| k != :notify_user }
.map { |k, v| { id: v, name: ReviewableScore.type_title(k) } },
reviewable_count: current_user.reviewable_count,
unseen_reviewable_count: Reviewable.unseen_reviewable_count(current_user),
),
}
if (offset + PER_PAGE) < total_rows
json[:meta][:load_more_reviewables] = review_path(filters.merge(offset: offset + PER_PAGE))
end
json.merge!(hash)
render_json_dump(json, rest_serializer: true)
end
def user_menu_list
json = {
reviewables:
Reviewable.basic_serializers_for_list(
Reviewable.user_menu_list_for(current_user),
current_user,
).as_json,
reviewable_count: current_user.reviewable_count,
}
render_json_dump(json, rest_serializer: true)
end
def count
render_json_dump(count: Reviewable.pending_count(current_user))
end
def topics
topic_ids = Set.new
stats = {}
unique_users = {}
# topics isn't indexed on `reviewable_score` and doesn't know what the current user can see,
# so let's query from the inside out.
pending = Reviewable.viewable_by(current_user).pending
pending = pending.where("score >= ?", Reviewable.min_score_for_priority)
pending.each do |r|
topic_ids << r.topic_id
meta = stats[r.topic_id] ||= { count: 0, unique_users: 0 }
users = unique_users[r.topic_id] ||= Set.new
r.reviewable_scores.each do |rs|
users << rs.user_id
meta[:count] += 1
end
meta[:unique_users] = users.size
end
topics = Topic.where(id: topic_ids).order("reviewable_score DESC")
render_serialized(
topics,
ReviewableTopicSerializer,
root: "reviewable_topics",
stats: stats,
claimed_topics: ReviewableClaimedTopic.claimed_hash(topic_ids),
rest_serializer: true,
meta: {
types: meta_types,
},
)
end
def explain
reviewable = find_reviewable
render_serialized(
{ reviewable: reviewable, scores: reviewable.explain_score },
ReviewableExplanationSerializer,
rest_serializer: true,
root: "reviewable_explanation",
)
end
def show
reviewable = find_reviewable
render_serialized(
reviewable,
reviewable.serializer,
rest_serializer: true,
claimed_topics: ReviewableClaimedTopic.claimed_hash([reviewable.topic_id]),
root: "reviewable",
meta: {
types: meta_types,
},
)
end
def destroy
user =
if is_api?
if @guardian.is_admin?
fetch_user_from_params
else
raise Discourse::InvalidAccess
end
else
current_user
end
reviewable =
Reviewable.find_by_flagger_or_queued_post_creator(
id: params[:reviewable_id],
user_id: user.id,
)
raise Discourse::NotFound.new if reviewable.blank?
reviewable.perform(current_user, :delete, { guardian: @guardian })
render json: success_json
end
def update
reviewable = find_reviewable
if error = claim_error?(reviewable)
return render_json_error(error)
end
editable = reviewable.editable_for(guardian)
raise Discourse::InvalidAccess.new if editable.blank?
# Validate parameters are all editable
edit_params = params[:reviewable] || {}
edit_params.each do |name, value|
if value.is_a?(ActionController::Parameters)
value.each do |pay_name, pay_value|
raise Discourse::InvalidAccess.new unless editable.has?("#{name}.#{pay_name}")
end
else
raise Discourse::InvalidAccess.new unless editable.has?(name)
end
end
begin
if reviewable.update_fields(edit_params, current_user, version: params[:version].to_i)
result = edit_params.merge(version: reviewable.version)
render json: result
else
render_json_error(reviewable.errors)
end
rescue Reviewable::UpdateConflict
render_json_error(I18n.t("reviewables.conflict"), status: 409)
end
end
def perform
args = { version: params[:version].to_i }
result = nil
begin
reviewable = find_reviewable
if error = claim_error?(reviewable)
return render_json_error(error)
end
if reviewable.type_class.respond_to?(:additional_args)
args.merge!(reviewable.type_class.additional_args(params) || {})
end
plugin_params =
DiscoursePluginRegistry.reviewable_params.select do |reviewable_param|
reviewable.type == reviewable_param[:type].to_s.classify
end
args.merge!(params.slice(*plugin_params.map { |pp| pp[:param] }).permit!)
result = reviewable.perform(current_user, params[:action_id].to_sym, args)
rescue Reviewable::InvalidAction => e
if reviewable.type == "ReviewableUser" && !reviewable.pending? && reviewable.target.blank?
raise Discourse::NotFound.new(
e.message,
custom_message: "reviewables.already_handled_and_user_not_exist",
)
else
# Consider InvalidAction an InvalidAccess
raise Discourse::InvalidAccess.new(e.message)
end
rescue Reviewable::UpdateConflict
return render_json_error(I18n.t("reviewables.conflict"), status: 409)
end
if result.success?
render_serialized(result, ReviewablePerformResultSerializer)
else
render_json_error(result)
end
end
def settings
raise Discourse::InvalidAccess.new unless current_user.admin?
post_action_types = PostActionType.where(id: PostActionType.flag_types.values).order("id")
if request.put?
params[:reviewable_priorities].each do |id, priority|
if !priority.nil? && Reviewable.priorities.has_value?(priority.to_i)
# For now, the score bonus is equal to the priority. In the future we might want
# to calculate it a different way.
PostActionType.where(id: id).update_all(
reviewable_priority: priority.to_i,
score_bonus: priority.to_f,
)
end
end
end
data = { reviewable_score_types: post_action_types }
render_serialized(data, ReviewableSettingsSerializer, rest_serializer: true)
end
protected
def claim_error?(reviewable)
return if SiteSetting.reviewable_claiming == "disabled" || reviewable.topic_id.blank?
claimed_by_id = ReviewableClaimedTopic.where(topic_id: reviewable.topic_id).pluck(:user_id)[0]
if SiteSetting.reviewable_claiming == "required" && claimed_by_id.blank?
I18n.t("reviewables.must_claim")
elsif claimed_by_id.present? && claimed_by_id != current_user.id
I18n.t("reviewables.user_claimed")
end
end
def find_reviewable
reviewable = Reviewable.viewable_by(current_user).where(id: params[:reviewable_id]).first
raise Discourse::NotFound.new if reviewable.blank?
reviewable
end
def allowed_statuses
@allowed_statuses ||= (%i[reviewed all] + Reviewable.statuses.symbolize_keys.keys)
end
def version_required
render_json_error(I18n.t("reviewables.missing_version"), status: 422) if params[:version].blank?
end
def meta_types
{ created_by: "user", target_created_by: "user", reviewed_by: "user", claimed_by: "user" }
end
def ensure_can_see
Guardian.new(current_user).ensure_can_see_review_queue!
end
def with_deleted_content
Post.unscoped { Topic.unscoped { PostAction.unscoped { yield } } }
end
end