Fix #4669: Track moderation report status.

* Add ability to mark moderation reports as 'handled' or 'rejected'.
* Automatically mark reports as handled when the comment or forum post
  is deleted.
* Send a dmail to the reporter when their report is handled.
* Don't show the report notice on comments or forum posts when all
  reports against it have been handled or rejected.
* Add a fix script to mark all existing reports for deleted comments,
  forum posts, or dmails as handled.
This commit is contained in:
evazion
2022-01-20 17:41:05 -06:00
parent 98aee048f2
commit c8d27c2719
20 changed files with 160 additions and 16 deletions

View File

@@ -42,6 +42,6 @@ class CommentComponent < ApplicationComponent
end
def reported?
policy(ModerationReport).can_see_moderation_reports? && comment.moderation_reports.present?
policy(ModerationReport).can_see_moderation_reports? && comment.pending_moderation_reports.present?
end
end

View File

@@ -85,7 +85,7 @@
<% if reported? %>
<li class="moderation-report-notice">
Reported (<%= link_to pluralize(comment.moderation_reports.length, "report"), moderation_reports_path(search: { model_type: "Comment", model_id: comment.id }) %>)
Reported (<%= link_to pluralize(comment.pending_moderation_reports.length, "report"), moderation_reports_path(search: { model_type: "Comment", model_id: comment.id, status: "pending" }) %>)
</li>
<% end %>

View File

@@ -12,7 +12,7 @@ class CommentSectionComponent < ApplicationComponent
@comments = @post.comments.order(id: :asc)
@comments = @comments.includes(:creator)
@comments = @comments.includes(:votes) if !current_user.is_anonymous?
@comments = @comments.includes(:moderation_reports) if policy(ModerationReport).can_see_moderation_reports?
@comments = @comments.includes(:pending_moderation_reports) if policy(ModerationReport).can_see_moderation_reports?
@comments = @comments.last(limit) if limit.present?
@dtext_data = DText.preprocess(@comments.map(&:body))

View File

@@ -1,9 +1,9 @@
# frozen_string_literal: true
class ForumPostComponent < ApplicationComponent
attr_reader :forum_post, :original_forum_post_id, :dtext_data, :moderation_reports, :current_user
attr_reader :forum_post, :original_forum_post_id, :dtext_data, :current_user
delegate :link_to_user, :time_ago_in_words_tagged, :format_text, :policy, :data_attributes_for, to: :helpers
delegate :link_to_user, :time_ago_in_words_tagged, :format_text, :data_attributes_for, to: :helpers
with_collection_parameter :forum_post
@@ -12,7 +12,7 @@ class ForumPostComponent < ApplicationComponent
original_forum_post_id = forum_topic.original_post&.id
forum_posts = forum_posts.includes(:creator, :bulk_update_request)
forum_posts = forum_posts.includes(:moderation_reports) if Pundit.policy!(current_user, ModerationReport).show?
forum_posts = forum_posts.includes(:pending_moderation_reports) if Pundit.policy(current_user, ModerationReport).can_see_moderation_reports?
super(forum_posts, dtext_data: dtext_data, original_forum_post_id: original_forum_post_id, current_user: current_user)
end
@@ -29,7 +29,7 @@ class ForumPostComponent < ApplicationComponent
policy(forum_post).show_deleted?
end
def has_moderation_reports?
policy(ModerationReport).can_see_moderation_reports? && forum_post.moderation_reports.present?
def reported?
policy(ModerationReport).can_see_moderation_reports? && forum_post.pending_moderation_reports.present?
end
end

View File

@@ -1,4 +1,4 @@
<%= tag.article class: "forum-post message", id: "forum_post_#{forum_post.id}", data: { "is-reported": has_moderation_reports?, **data_attributes_for(forum_post, "", forum_post.html_data_attributes) } do %>
<%= tag.article class: "forum-post message", id: "forum_post_#{forum_post.id}", data: { "is-reported": reported?, **data_attributes_for(forum_post, "", forum_post.html_data_attributes) } do %>
<div class="author">
<div class="author-name">
<%= link_to_user forum_post.creator %>
@@ -24,8 +24,10 @@
<% end %>
<% end %>
<% if has_moderation_reports? %>
<li class="moderation-report-notice">Reported (<%= link_to pluralize(forum_post.moderation_reports.length, "report"), moderation_reports_path(search: { model_type: "ForumPost", model_id: forum_post.id }) %>)</li>
<% if reported? %>
<li class="moderation-report-notice">
Reported (<%= link_to pluralize(forum_post.pending_moderation_reports.length, "report"), moderation_reports_path(search: { model_type: "ForumPost", model_id: forum_post.id, status: "pending" }) %>)
</li>
<% end %>
<%= render PopupMenuComponent.new do |menu| %>

View File

@@ -29,4 +29,12 @@ class ModerationReportsController < ApplicationController
flash.now[:notice] = @moderation_report.valid? ? "Report submitted" : @moderation_report.errors.full_messages.join("; ")
respond_with(@moderation_report)
end
def update
@moderation_report = authorize ModerationReport.find(params[:id])
@moderation_report.update(permitted_attributes(@moderation_report))
flash.now[:notice] = @moderation_report.valid? ? "Report updated" : @moderation_report.errors.full_messages.join("; ")
respond_with(@moderation_report)
end
end

View File

@@ -6,11 +6,13 @@ class Comment < ApplicationRecord
belongs_to_updater
has_many :moderation_reports, as: :model, dependent: :destroy
has_many :pending_moderation_reports, -> { pending }, as: :model, class_name: "ModerationReport"
has_many :votes, class_name: "CommentVote", dependent: :destroy
validates :body, presence: true, length: { maximum: 15_000 }, if: :body_changed?
before_create :autoreport_spam
before_save :handle_reports_on_deletion
after_create :update_last_commented_at_on_create
after_update(:if => ->(rec) {(!rec.is_deleted? || !rec.saved_change_to_is_deleted?) && CurrentUser.id != rec.creator_id}) do |rec|
ModAction.log("comment ##{rec.id} updated by #{CurrentUser.user.name}", :comment_update)
@@ -78,6 +80,13 @@ class Comment < ApplicationRecord
end
end
def handle_reports_on_deletion
return unless Pundit.policy!(updater, ModerationReport).update?
return unless moderation_reports.pending.present? && is_deleted_change == [false, true]
moderation_reports.pending.update!(status: :handled)
end
def quoted_response
DText.quote(body, creator.name)
end

View File

@@ -8,6 +8,7 @@ class ForumPost < ApplicationRecord
belongs_to :topic, class_name: "ForumTopic", inverse_of: :forum_posts
has_many :moderation_reports, as: :model
has_many :pending_moderation_reports, -> { pending }, as: :model, class_name: "ModerationReport"
has_many :votes, class_name: "ForumPostVote"
has_one :tag_alias
has_one :tag_implication
@@ -16,6 +17,7 @@ class ForumPost < ApplicationRecord
validates :body, presence: true, length: { maximum: 200_000 }, if: :body_changed?
before_create :autoreport_spam
before_save :handle_reports_on_deletion
after_create :update_topic_updated_at_on_create
after_update :update_topic_updated_at_on_update_for_original_posts
after_destroy :update_topic_updated_at_on_destroy
@@ -157,6 +159,12 @@ class ForumPost < ApplicationRecord
end
end
def handle_reports_on_deletion
return unless moderation_reports.pending.present? && is_deleted_change == [false, true]
moderation_reports.pending.update!(status: :handled)
end
def async_send_discord_notification
DiscordNotificationJob.perform_later(forum_post: self)
end

View File

@@ -12,12 +12,19 @@ class ModerationReport < ApplicationRecord
after_create :create_forum_post!
after_create :autoban_reported_user
after_save :notify_reporter
scope :dmail, -> { where(model_type: "Dmail") }
scope :comment, -> { where(model_type: "Comment") }
scope :forum_post, -> { where(model_type: "ForumPost") }
scope :recent, -> { where("moderation_reports.created_at >= ?", 1.week.ago) }
enum status: {
pending: 0,
rejected: 1,
handled: 2,
}
def self.model_types
MODEL_TYPES
end
@@ -73,6 +80,15 @@ class ModerationReport < ApplicationRecord
end
end
def notify_reporter
return if creator == User.system
return unless handled? && status_before_last_save != :handled
Dmail.create_automated(to: creator, title: "Thank you for reporting #{model.dtext_shortlink}", body: <<~EOS)
Thank you for reporting #{model.dtext_shortlink}. Action has been taken against the user.
EOS
end
def reported_user
case model
when Comment, ForumPost
@@ -85,7 +101,7 @@ class ModerationReport < ApplicationRecord
end
def self.search(params)
q = search_attributes(params, :id, :created_at, :updated_at, :reason, :creator, :model)
q = search_attributes(params, :id, :created_at, :updated_at, :reason, :creator, :model, :status)
q = q.text_attribute_matches(:reason, params[:reason_matches])
q.apply_default_order(params)

View File

@@ -13,11 +13,19 @@ class ModerationReportPolicy < ApplicationPolicy
unbanned? && policy(record.model).reportable?
end
def update?
user.is_moderator?
end
def can_see_moderation_reports?
user.is_moderator?
end
def permitted_attributes
def permitted_attributes_for_create
[:model_type, :model_id, :reason]
end
def permitted_attributes_for_update
[:status]
end
end

View File

@@ -7,6 +7,7 @@
<%= f.input :reason_matches, label: "Reason", input_html: { value: params[:search][:reason_matches] } %>
<%= f.input :model_id, label: "ID", input_html: { value: params[:search][:model_id] } %>
<%= f.input :model_type, label: "Type", collection: ModerationReport.model_types, include_blank: true, selected: params[:search][:model_type] %>
<%= f.input :status, label: "Status", collection: ModerationReport.statuses.keys, include_blank: true, selected: params[:search][:status] %>
<%= f.submit "Search" %>
<% end %>
@@ -26,6 +27,10 @@
</span>
<% end %>
<% t.column "Status" do |report| %>
<%= link_to report.status.capitalize, moderation_reports_path(search: { status: report.status }) %>
<% end %>
<% t.column "Reported user" do |report| %>
<%= link_to_user report.reported_user %>
<% end %>
@@ -35,6 +40,18 @@
<%= link_to "»", moderation_reports_path(search: { creator_name: report.creator.name }) %>
<div><%= time_ago_in_words_tagged(report.created_at) %></div>
<% end %>
<% t.column column: "control" do |report| %>
<%= render PopupMenuComponent.new do |menu| %>
<% menu.item do %>
<%= link_to "Mark as handled", moderation_report_path(report.id), method: :put, remote: true, "data-params": "moderation_report[status]=handled" %>
<% end %>
<% menu.item do %>
<%= link_to "Reject", moderation_report_path(report.id), method: :put, remote: true, "data-params": "moderation_report[status]=rejected" %>
<% end %>
<% end %>
<% end %>
<% end %>
<%= numbered_paginator(@moderation_reports) %>

View File

@@ -0,0 +1 @@
location.reload();

View File

@@ -158,7 +158,7 @@ Rails.application.routes.draw do
resources :media_assets, only: [:index, :show]
resources :media_metadata, only: [:index]
resources :mod_actions
resources :moderation_reports, only: [:new, :create, :index, :show]
resources :moderation_reports, only: [:new, :create, :index, :show, :update]
resources :modqueue, only: [:index]
resources :news_updates
resources :notes do

View File

@@ -0,0 +1,6 @@
class AddStatusToModerationReports < ActiveRecord::Migration[7.0]
def change
add_column :moderation_reports, :status, :integer, null: false, default: 0
add_index :moderation_reports, :status
end
end

View File

@@ -1202,7 +1202,8 @@ CREATE TABLE public.moderation_reports (
model_type character varying NOT NULL,
model_id bigint NOT NULL,
creator_id integer NOT NULL,
reason text NOT NULL
reason text NOT NULL,
status integer DEFAULT 0 NOT NULL
);
@@ -3847,6 +3848,13 @@ CREATE INDEX index_moderation_reports_on_creator_id ON public.moderation_reports
CREATE INDEX index_moderation_reports_on_model_type_and_model_id ON public.moderation_reports USING btree (model_type, model_id);
--
-- Name: index_moderation_reports_on_status; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX index_moderation_reports_on_status ON public.moderation_reports USING btree (status);
--
-- Name: index_news_updates_on_created_at; Type: INDEX; Schema: public; Owner: -
--
@@ -5688,6 +5696,7 @@ INSERT INTO "schema_migrations" (version) VALUES
('20220110171021'),
('20220110171022'),
('20220110171023'),
('20220110171024');
('20220110171024'),
('20220120233850');

View File

@@ -0,0 +1,9 @@
#!/usr/bin/env ruby
require_relative "base"
with_confirmation do
ModerationReport.pending.where(model: Comment.deleted).update_all(status: :handled, updated_at: Time.zone.now)
ModerationReport.pending.where(model: ForumPost.deleted).update_all(status: :handled, updated_at: Time.zone.now)
ModerationReport.pending.where(model: Dmail.deleted).update_all(status: :handled, updated_at: Time.zone.now)
end

View File

@@ -2,5 +2,6 @@ FactoryBot.define do
factory(:moderation_report) do
creator
reason {"xxx"}
status { :pending }
end
end

View File

@@ -243,6 +243,18 @@ class CommentsControllerTest < ActionDispatch::IntegrationTest
assert_equal(true, @comment.reload.is_deleted)
assert_redirected_to @comment
end
should "mark all pending moderation reports against the comment as handled" do
@comment = create(:comment, post: @post)
report1 = create(:moderation_report, model: @comment, status: :pending)
report2 = create(:moderation_report, model: @comment, status: :rejected)
delete_auth comment_path(@comment.id), @mod
assert_redirected_to @comment
assert_equal(true, @comment.reload.is_deleted)
assert_equal(true, report1.reload.handled?)
assert_equal(true, report2.reload.rejected?)
end
end
context "undelete action" do

View File

@@ -229,6 +229,17 @@ class ForumPostsControllerTest < ActionDispatch::IntegrationTest
assert_response 403
assert_equal(false, @forum_post.reload.is_deleted?)
end
should "mark all pending moderation reports against the post as handled" do
report1 = create(:moderation_report, model: @forum_post, status: :pending)
report2 = create(:moderation_report, model: @forum_post, status: :rejected)
delete_auth forum_post_path(@forum_post), @mod
assert_redirected_to(forum_post_path(@forum_post))
assert_equal(true, @forum_post.reload.is_deleted?)
assert_equal(true, report1.reload.handled?)
assert_equal(true, report2.reload.rejected?)
end
end
context "undelete action" do

View File

@@ -104,5 +104,32 @@ class ModerationReportsControllerTest < ActionDispatch::IntegrationTest
end
end
end
context "update action" do
should "not allow non-mods to update moderation reports" do
report = create(:moderation_report, model: @comment, creator: @user)
put_auth moderation_report_path(report), @user, params: { moderation_report: { status: "handled" }}, xhr: true
assert_response 403
end
should "allow a moderator to mark a moderation report as handled" do
report = create(:moderation_report, model: @comment, creator: @user)
put_auth moderation_report_path(report), @mod, params: { moderation_report: { status: "handled" }}, xhr: true
assert_response :success
assert_equal("handled", report.reload.status)
assert_equal(true, @user.dmails.received.exists?(from: User.system, title: "Thank you for reporting comment ##{@comment.id}"))
end
should "allow a moderator to mark a moderation report as rejected" do
report = create(:moderation_report, model: @comment, creator: @user)
put_auth moderation_report_path(report), @mod, params: { moderation_report: { status: "rejected" }}, xhr: true
assert_response :success
assert_equal("rejected", report.reload.status)
assert_equal(false, @user.dmails.received.exists?(from: User.system))
end
end
end
end