Fix #4568: Send appealed posts back to the mod queue

* Include appealed posts in the modqueue.

* Add `status` field to appeals. Appeals start out as `pending`, then
  become `rejected` if the post isn't approved within three days. If the
  post is approved, the appeal's status becomes `succeeded`.

* Add `status` field to flags. Flags start out as `pending` then become
  `rejected` if the post is approved within three days. If the post
  isn't approved, the flag's status becomes `succeeded`.

* Leave behind a "Unapproved in three days" dummy flag when an appeal
  goes unapproved, just like when a pending post is unapproved.

* Only allow deleted posts to be appealed. Don't allow flagged posts to be appealed.

* Add `status:appealed` metatag. `status:appealed` is separate from `status:pending`.

* Include appealed posts in `status:modqueue`. Search `status:modqueue order:modqueue`
  to view the modqueue as a normal search.

* Retroactively set old flags and appeals as succeeded or rejected. This
  may not be correct for posts that were appealed or flagged multiple
  times. This is difficult to set correctly because we don't have
  approval records for old posts, so we can't tell the actual outcome of
  old flags and appeals.

* Deprecate the `is_resolved` field on post flags. A resolved flag is a
  flag that isn't pending.

* Known bug: appealed posts have a black border instead of a blue
  border. Checking whether a post has been appealed would require either
  an extra query on the posts/index page, or an is_appealed flag on
  posts, neither of which are very desirable.

* Known bug: you can't use `status:appealed` in blacklists, for the same
  reason as above.
This commit is contained in:
evazion
2020-08-03 14:40:04 -05:00
parent e31afd0827
commit 0a0a85ee70
31 changed files with 372 additions and 118 deletions

View File

@@ -4,14 +4,14 @@ class ModqueueController < ApplicationController
def index
authorize :modqueue
@posts = Post.includes(:appeals, :disapprovals, :uploader, flags: [:creator]).pending_or_flagged.available_for_moderation(CurrentUser.user, hidden: search_params[:hidden])
@posts = Post.includes(:appeals, :disapprovals, :uploader, flags: [:creator]).in_modqueue.available_for_moderation(CurrentUser.user, hidden: search_params[:hidden])
@posts = @posts.paginated_search(params, order: "modqueue", count_pages: true)
@modqueue_posts = @posts.except(:offset, :limit, :order)
@pending_post_count = @modqueue_posts.pending.count
@flagged_post_count = @modqueue_posts.flagged.count
@disapproval_reasons = PostDisapproval.where(post: @modqueue_posts).where.not(reason: "disinterest").group(:reason).order(count: :desc).distinct.count(:post_id)
@uploaders = @modqueue_posts.group(:uploader).order(count: :desc).limit(20).count
@modqueue_posts = @posts.reselect(nil).reorder(nil).offset(nil).limit(nil)
@pending_post_count = @modqueue_posts.select(&:is_pending?).count
@flagged_post_count = @modqueue_posts.select(&:is_flagged?).count
@disapproval_reasons = PostDisapproval.where(post: @modqueue_posts.reselect(:id)).where.not(reason: "disinterest").group(:reason).order(count: :desc).distinct.count(:post_id)
@uploaders = @modqueue_posts.map(&:uploader).tally.sort_by(&:last).reverse.take(20).to_h
@tags = RelatedTagCalculator.frequent_tags_for_post_relation(@modqueue_posts)
@artist_tags = @tags.select(&:artist?).sort_by(&:overlap_count).reverse.take(10)

View File

@@ -1,7 +1,10 @@
class PostPruner
module PostPruner
module_function
def prune!
prune_pending!
prune_flagged!
prune_appealed!
end
def prune_pending!
@@ -11,10 +14,14 @@ class PostPruner
end
def prune_flagged!
Post.flagged.each do |post|
if post.flags.unresolved.old.any?
post.delete!("Unapproved in three days after returning to moderation queue", user: User.system)
end
PostFlag.expired.each do |flag|
flag.post.delete!("Unapproved in three days after returning to moderation queue", user: User.system)
end
end
def prune_appealed!
PostAppeal.expired.each do |appeal|
appeal.post.delete!("Unapproved in three days after returning to moderation queue", user: User.system)
end
end
end

View File

@@ -274,8 +274,10 @@ class PostQueryBuilder
Post.pending
when "flagged"
Post.flagged
when "appealed"
Post.appealed
when "modqueue"
Post.pending_or_flagged
Post.in_modqueue
when "deleted"
Post.deleted
when "banned"
@@ -283,7 +285,7 @@ class PostQueryBuilder
when "active"
Post.active
when "unmoderated"
Post.pending_or_flagged.available_for_moderation(current_user, hidden: false)
Post.in_modqueue.available_for_moderation(current_user, hidden: false)
when "all", "any"
Post.all
else
@@ -307,7 +309,7 @@ class PostQueryBuilder
Post.where(parent: nil)
when "any"
Post.where.not(parent: nil)
when /pending|flagged|modqueue|deleted|banned|active|unmoderated/
when "pending", "flagged", "appealed", "modqueue", "deleted", "banned", "active", "unmoderated"
Post.where.not(parent: nil).where(parent: status_matches(parent))
when /\A\d+\z/
Post.where(id: parent).or(Post.where(parent: parent))
@@ -322,7 +324,7 @@ class PostQueryBuilder
Post.where(has_children: false)
when "any"
Post.where(has_children: true)
when /pending|flagged|modqueue|deleted|banned|active|unmoderated/
when "pending", "flagged", "appealed", "modqueue", "deleted", "banned", "active", "unmoderated"
Post.where(has_children: true).where(children: status_matches(child))
else
Post.none
@@ -606,10 +608,10 @@ class PostQueryBuilder
.order("contributor_fav_count DESC, posts.fav_count DESC, posts.id DESC")
when "modqueue", "modqueue_desc"
relation = relation.left_outer_joins(:flags).order(Arel.sql("GREATEST(posts.created_at, post_flags.created_at) DESC, posts.id DESC"))
relation = relation.with_queued_at.order("queued_at DESC, posts.id DESC")
when "modqueue_asc"
relation = relation.left_outer_joins(:flags).order(Arel.sql("GREATEST(posts.created_at, post_flags.created_at) ASC, posts.id ASC"))
relation = relation.with_queued_at.order("queued_at ASC, posts.id ASC")
when "none"
relation = relation.reorder(nil)

View File

@@ -149,7 +149,7 @@ module PostSets
end
def not_shown(post)
post.is_deleted? && tag_string !~ /status:(?:all|any|deleted|banned)/
post.is_deleted? && tag_string !~ /status:(?:all|any|deleted|banned|modqueue)/
end
def none_shown

View File

@@ -111,6 +111,10 @@ class ApplicationRecord < ActiveRecord::Base
ensure
connection.execute("SET STATEMENT_TIMEOUT = #{CurrentUser.user.try(:statement_timeout) || 3_000}") unless Rails.env == "test"
end
def update!(*args)
all.each { |record| record.update!(*args) }
end
end
end

View File

@@ -61,8 +61,9 @@ class Post < ApplicationRecord
scope :pending, -> { where(is_pending: true) }
scope :flagged, -> { where(is_flagged: true) }
scope :banned, -> { where(is_banned: true) }
scope :active, -> { where(is_pending: false, is_deleted: false, is_flagged: false) }
scope :pending_or_flagged, -> { pending.or(flagged) }
scope :active, -> { where(is_pending: false, is_deleted: false, is_flagged: false).where.not(id: PostAppeal.pending) }
scope :appealed, -> { where(id: PostAppeal.pending.select(:post_id)) }
scope :in_modqueue, -> { pending.or(flagged).or(appealed) }
scope :expired, -> { where("posts.created_at < ?", 3.days.ago) }
scope :unflagged, -> { where(is_flagged: false) }
@@ -281,8 +282,24 @@ class Post < ApplicationRecord
end
module ApprovalMethods
def in_modqueue?
is_pending? || is_flagged? || is_appealed?
end
def is_active?
!is_deleted? && !in_modqueue?
end
def is_appealed?
is_deleted? && appeals.any?(&:pending?)
end
def is_appealable?
is_deleted? && !is_appealed?
end
def is_approvable?(user = CurrentUser.user)
!is_status_locked? && (is_pending? || is_flagged? || is_deleted?) && uploader != user
!is_status_locked? && !is_active? && uploader != user
end
def flag!(reason, is_deletion: false)
@@ -991,7 +1008,10 @@ class Post < ApplicationRecord
transaction do
automated = (user == User.system)
flags.create!(reason: reason, is_deletion: true, creator: user)
flags.pending.update!(status: :succeeded)
appeals.pending.update!(status: :rejected)
flags.create!(reason: reason, is_deletion: true, creator: user, status: :succeeded)
update!(is_deleted: true, is_pending: false, is_flagged: false)
# XXX This must happen *after* the `is_deleted` flag is set to true (issue #3419).
@@ -1224,6 +1244,14 @@ class Post < ApplicationRecord
relation
end
def with_queued_at
relation = group(:id)
relation = relation.left_outer_joins(:flags, :appeals)
relation = relation.select("posts.*")
relation = relation.select(Arel.sql("MAX(GREATEST(posts.created_at, post_flags.created_at, post_appeals.created_at)) AS queued_at"))
relation
end
def with_stats(tables)
return all if tables.empty?

View File

@@ -5,20 +5,27 @@ class PostAppeal < ApplicationRecord
belongs_to :creator, :class_name => "User"
belongs_to :post
validates_presence_of :reason
validates :reason, presence: true, length: { in: 1..140 }
validate :validate_post_is_inactive
validate :validate_creator_is_not_limited
validates_uniqueness_of :creator_id, :scope => :post_id, :message => "have already appealed this post"
validate :validate_post_is_appealable, on: :create
validate :validate_creator_is_not_limited, on: :create
validates :creator, uniqueness: { scope: :post, message: "have already appealed this post" }, on: :create
enum status: {
pending: 0,
succeeded: 1,
rejected: 2
}
scope :resolved, -> { where(post: Post.undeleted.unflagged) }
scope :unresolved, -> { where(post: Post.deleted.or(Post.flagged)) }
scope :recent, -> { where("post_appeals.created_at >= ?", 1.day.ago) }
scope :expired, -> { pending.where("post_appeals.created_at <= ?", 3.days.ago) }
module SearchMethods
def search(params)
q = super
q = q.search_attributes(params, :creator, :post, :reason)
q = q.search_attributes(params, :creator, :post, :reason, :status)
q = q.text_attribute_matches(:reason, params[:reason_matches])
q = q.resolved if params[:is_resolved].to_s.truthy?
@@ -44,10 +51,8 @@ class PostAppeal < ApplicationRecord
end
end
def validate_post_is_inactive
if resolved?
errors[:post] << "is active"
end
def validate_post_is_appealable
errors[:post] << "cannot be appealed" if post.is_status_locked? || !post.is_appealable?
end
def appeal_count_for_creator

View File

@@ -12,7 +12,7 @@ class PostApproval < ApplicationRecord
errors.add(:post, "is locked and cannot be approved")
end
if post.status == "active"
if post.is_active?
errors.add(:post, "is already active and cannot be approved")
end
@@ -28,7 +28,9 @@ class PostApproval < ApplicationRecord
def approve_post
is_undeletion = post.is_deleted
post.flags.each(&:resolve!)
post.flags.pending.update!(status: :rejected)
post.appeals.pending.update!(status: :succeeded)
post.update(approver: user, is_flagged: false, is_pending: false, is_deleted: false)
ModAction.log("undeleted post ##{post_id}", :post_undelete) if is_undeletion

View File

@@ -46,7 +46,7 @@ class PostDisapproval < ApplicationRecord
end
def validate_disapproval
if post.status == "active"
if post.is_active?
errors[:post] << "is already active and cannot be disapproved"
end
end

View File

@@ -17,13 +17,19 @@ class PostFlag < ApplicationRecord
before_save :update_post
attr_accessor :is_deletion
enum status: {
pending: 0,
succeeded: 1,
rejected: 2
}
scope :by_users, -> { where.not(creator: User.system) }
scope :by_system, -> { where(creator: User.system) }
scope :in_cooldown, -> { by_users.where("created_at >= ?", COOLDOWN_PERIOD.ago) }
scope :resolved, -> { where(is_resolved: true) }
scope :unresolved, -> { where(is_resolved: false) }
scope :recent, -> { where("post_flags.created_at >= ?", 1.day.ago) }
scope :old, -> { where("post_flags.created_at <= ?", 3.days.ago) }
scope :expired, -> { pending.where("post_flags.created_at <= ?", 3.days.ago) }
module SearchMethods
def creator_matches(creator, searcher)
@@ -56,7 +62,7 @@ class PostFlag < ApplicationRecord
def search(params)
q = super
q = q.search_attributes(params, :post, :is_resolved, :reason)
q = q.search_attributes(params, :post, :is_resolved, :reason, :status)
q = q.text_attribute_matches(:reason, params[:reason_matches])
if params[:creator_id].present?
@@ -113,12 +119,8 @@ class PostFlag < ApplicationRecord
def validate_post
errors[:post] << "is pending and cannot be flagged" if post.is_pending? && !is_deletion
errors[:post] << "is deleted and cannot be flagged" if post.is_deleted? && !is_deletion
errors[:post] << "is locked and cannot be flagged" if post.is_status_locked?
errors[:post] << "is deleted" if post.is_deleted?
end
def resolve!
update_column(:is_resolved, true)
end
def flag_count_for_creator

View File

@@ -7,7 +7,7 @@ class PostPresenter
return "<em>none</em>".html_safe
end
if !options[:show_deleted] && post.is_deleted? && options[:tags] !~ /status:(?:all|any|deleted|banned)/
if !options[:show_deleted] && post.is_deleted? && options[:tags] !~ /status:(?:all|any|deleted|banned|modqueue)/
return ""
end

View File

@@ -1,6 +1,6 @@
<%= content_tag(:div, { id: "post-#{post.id}", class: ["post", "mod-queue-preview", "column-container", *PostPresenter.preview_class(post)].join(" ") }.merge(PostPresenter.data_attributes(post))) do %>
<aside class="column column-shrink">
<%= PostPresenter.preview(post, size: true) %>
<%= PostPresenter.preview(post, size: true, show_deleted: true) %>
</aside>
<section class="column column-expand">

View File

@@ -43,22 +43,18 @@
Status:
<% if post.is_pending? %>
Pending
<% end %>
<% if post.is_deleted? %>
Deleted
<% end %>
<% if post.is_flagged? %>
<% elsif post.is_flagged? %>
Flagged
<% elsif post.is_appealed? %>
Appealed
<% elsif post.is_deleted? %>
Deleted
<% else %>
Active
<% end %>
<% if post.is_banned? %>
Banned
<% end %>
<% if !post.is_pending? && !post.is_deleted? && !post.is_banned? %>
Active
<% end %>
</li>
</ul>

View File

@@ -1,4 +1,4 @@
<% if post.is_flagged? && !post.is_deleted? && post.flags.any? %>
<% if post.is_flagged? %>
<div class="notice notice-small post-notice post-notice-flagged">
<p>This post was flagged for review (<%= link_to_wiki "learn more", "howto:flag" %>): </p>
@@ -30,13 +30,14 @@
</div>
<% end %>
<% if post.is_pending? || post.is_flagged? %>
<% if post.in_modqueue? %>
<div class="notice notice-small post-notice post-notice-pending">
<% if post.is_pending? %>
This post is pending approval.
(<%= link_to_wiki "learn more", "about:mod_queue" %>)
<% else %>
This post is pending approval. (<%= link_to_wiki "learn more", "about:mod_queue" %>)
<% elsif post.is_flagged? %>
This post was flagged and is pending approval (<%= link_to_wiki "learn more", "about:mod_queue" %>)
<% elsif post.is_appealed? %>
This post was appealed and is pending approval (<%= link_to_wiki "learn more", "about:mod_queue" %>)
<% end %>
<%= render "post_disapprovals/counts", :disapprovals => post.disapprovals, :post => post %>
@@ -48,7 +49,8 @@
</div>
<% end %>
<% if (post.is_flagged? || post.is_deleted?) && post.appeals.any? %>
<% #XXX %>
<% if post.is_deleted? && post.appeals.any? %>
<div class="notice notice-small post-notice post-notice-appealed">
<p>This post was appealed:</p>
<%= render "post_appeals/reasons", appeals: post.appeals %>

View File

@@ -51,25 +51,24 @@
<% if post.is_status_locked? %>
<li id="post-option-status-locked">Status locked</li>
<% else %>
<% if (!post.is_deleted? && !post.is_pending? && !post.is_flagged?) && policy(PostFlag).create? %>
<% if post.is_active? && policy(PostFlag).create? %>
<li id="post-option-flag"><%= link_to "Flag", new_post_flag_path(post_flag: { post_id: post.id }), remote: true %></li>
<% elsif (post.is_flagged? || post.is_deleted?) && policy(PostAppeal).create? %>
<% elsif post.is_appealable? && policy(PostAppeal).create? %>
<li id="post-option-appeal"><%= link_to "Appeal", new_post_appeal_path(post_appeal: { post_id: post.id }), remote: true %></li>
<% end %>
<% if policy(PostApproval).create? %>
<% if post.is_deleted? %>
<li id="post-option-undelete"><%= link_to "Undelete", post_approvals_path(post_id: post.id), remote: true, method: :post, "data-confirm": "Are you sure you want to undelete this post?" %></li>
<% if policy(post).move_favorites? %>
<li id="post-option-move-favorites"><%= link_to "Move favorites", confirm_move_favorites_moderator_post_post_path(post_id: post.id) %></li>
<% end %>
<% elsif policy(post).delete? %>
<li id="post-option-delete"><%= link_to "Delete", post, method: :delete, remote: true %></li>
<% if post.is_approvable? %>
<li id="post-option-approve"><%= link_to (post.is_deleted? ? "Undelete" : "Approve"), post_approvals_path(post_id: post.id), remote: true, method: :post, "data-shortcut": "shift+o", "data-confirm": "Are you sure you want to approve this post?" %></li>
<li id="post-option-disapprove"><%= link_to "Hide from queue", post_disapprovals_path(post_disapproval: { post_id: post.id, reason: "disinterest" }), remote: true, method: :post %></li>
<% end %>
<% if post.is_approvable? && !post.is_deleted? %>
<li id="post-option-approve"><%= link_to "Approve", post_approvals_path(post_id: post.id), remote: true, method: :post, id: "approve", "data-shortcut": "shift+o", "data-confirm": "Are you sure you want to approve this post?" %></li>
<li id="post-option-disapprove"><%= link_to "Hide from queue", post_disapprovals_path(post_disapproval: { post_id: post.id, reason: "disinterest" }), remote: true, method: :post, id: "disapprove" %></li>
<% if post.is_deleted? && policy(post).move_favorites? %>
<li id="post-option-move-favorites"><%= link_to "Move favorites", confirm_move_favorites_moderator_post_post_path(post_id: post.id) %></li>
<% end %>
<% if !post.is_deleted? && policy(post).delete? %>
<li id="post-option-delete"><%= link_to "Delete", post, method: :delete, remote: true %></li>
<% end %>
<% if policy(post).unban? %>