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 def index
authorize :modqueue 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) @posts = @posts.paginated_search(params, order: "modqueue", count_pages: true)
@modqueue_posts = @posts.except(:offset, :limit, :order) @modqueue_posts = @posts.reselect(nil).reorder(nil).offset(nil).limit(nil)
@pending_post_count = @modqueue_posts.pending.count @pending_post_count = @modqueue_posts.select(&:is_pending?).count
@flagged_post_count = @modqueue_posts.flagged.count @flagged_post_count = @modqueue_posts.select(&:is_flagged?).count
@disapproval_reasons = PostDisapproval.where(post: @modqueue_posts).where.not(reason: "disinterest").group(:reason).order(count: :desc).distinct.count(:post_id) @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.group(:uploader).order(count: :desc).limit(20).count @uploaders = @modqueue_posts.map(&:uploader).tally.sort_by(&:last).reverse.take(20).to_h
@tags = RelatedTagCalculator.frequent_tags_for_post_relation(@modqueue_posts) @tags = RelatedTagCalculator.frequent_tags_for_post_relation(@modqueue_posts)
@artist_tags = @tags.select(&:artist?).sort_by(&:overlap_count).reverse.take(10) @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! def prune!
prune_pending! prune_pending!
prune_flagged! prune_flagged!
prune_appealed!
end end
def prune_pending! def prune_pending!
@@ -11,10 +14,14 @@ class PostPruner
end end
def prune_flagged! def prune_flagged!
Post.flagged.each do |post| PostFlag.expired.each do |flag|
if post.flags.unresolved.old.any? flag.post.delete!("Unapproved in three days after returning to moderation queue", user: User.system)
post.delete!("Unapproved in three days after returning to moderation queue", user: User.system)
end end
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
end end

View File

@@ -274,8 +274,10 @@ class PostQueryBuilder
Post.pending Post.pending
when "flagged" when "flagged"
Post.flagged Post.flagged
when "appealed"
Post.appealed
when "modqueue" when "modqueue"
Post.pending_or_flagged Post.in_modqueue
when "deleted" when "deleted"
Post.deleted Post.deleted
when "banned" when "banned"
@@ -283,7 +285,7 @@ class PostQueryBuilder
when "active" when "active"
Post.active Post.active
when "unmoderated" 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" when "all", "any"
Post.all Post.all
else else
@@ -307,7 +309,7 @@ class PostQueryBuilder
Post.where(parent: nil) Post.where(parent: nil)
when "any" when "any"
Post.where.not(parent: nil) 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)) Post.where.not(parent: nil).where(parent: status_matches(parent))
when /\A\d+\z/ when /\A\d+\z/
Post.where(id: parent).or(Post.where(parent: parent)) Post.where(id: parent).or(Post.where(parent: parent))
@@ -322,7 +324,7 @@ class PostQueryBuilder
Post.where(has_children: false) Post.where(has_children: false)
when "any" when "any"
Post.where(has_children: true) 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)) Post.where(has_children: true).where(children: status_matches(child))
else else
Post.none Post.none
@@ -606,10 +608,10 @@ class PostQueryBuilder
.order("contributor_fav_count DESC, posts.fav_count DESC, posts.id DESC") .order("contributor_fav_count DESC, posts.fav_count DESC, posts.id DESC")
when "modqueue", "modqueue_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" 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" when "none"
relation = relation.reorder(nil) relation = relation.reorder(nil)

View File

@@ -149,7 +149,7 @@ module PostSets
end end
def not_shown(post) 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 end
def none_shown def none_shown

View File

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

View File

@@ -61,8 +61,9 @@ class Post < ApplicationRecord
scope :pending, -> { where(is_pending: true) } scope :pending, -> { where(is_pending: true) }
scope :flagged, -> { where(is_flagged: true) } scope :flagged, -> { where(is_flagged: true) }
scope :banned, -> { where(is_banned: true) } scope :banned, -> { where(is_banned: true) }
scope :active, -> { where(is_pending: false, is_deleted: false, is_flagged: false) } scope :active, -> { where(is_pending: false, is_deleted: false, is_flagged: false).where.not(id: PostAppeal.pending) }
scope :pending_or_flagged, -> { pending.or(flagged) } 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 :expired, -> { where("posts.created_at < ?", 3.days.ago) }
scope :unflagged, -> { where(is_flagged: false) } scope :unflagged, -> { where(is_flagged: false) }
@@ -281,8 +282,24 @@ class Post < ApplicationRecord
end end
module ApprovalMethods 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) 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 end
def flag!(reason, is_deletion: false) def flag!(reason, is_deletion: false)
@@ -991,7 +1008,10 @@ class Post < ApplicationRecord
transaction do transaction do
automated = (user == User.system) 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) 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). # XXX This must happen *after* the `is_deleted` flag is set to true (issue #3419).
@@ -1224,6 +1244,14 @@ class Post < ApplicationRecord
relation relation
end 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) def with_stats(tables)
return all if tables.empty? return all if tables.empty?

View File

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

View File

@@ -12,7 +12,7 @@ class PostApproval < ApplicationRecord
errors.add(:post, "is locked and cannot be approved") errors.add(:post, "is locked and cannot be approved")
end end
if post.status == "active" if post.is_active?
errors.add(:post, "is already active and cannot be approved") errors.add(:post, "is already active and cannot be approved")
end end
@@ -28,7 +28,9 @@ class PostApproval < ApplicationRecord
def approve_post def approve_post
is_undeletion = post.is_deleted 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) post.update(approver: user, is_flagged: false, is_pending: false, is_deleted: false)
ModAction.log("undeleted post ##{post_id}", :post_undelete) if is_undeletion ModAction.log("undeleted post ##{post_id}", :post_undelete) if is_undeletion

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ class PostPresenter
return "<em>none</em>".html_safe return "<em>none</em>".html_safe
end 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 "" return ""
end 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 %> <%= 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"> <aside class="column column-shrink">
<%= PostPresenter.preview(post, size: true) %> <%= PostPresenter.preview(post, size: true, show_deleted: true) %>
</aside> </aside>
<section class="column column-expand"> <section class="column column-expand">

View File

@@ -43,22 +43,18 @@
Status: Status:
<% if post.is_pending? %> <% if post.is_pending? %>
Pending Pending
<% end %> <% elsif post.is_flagged? %>
<% if post.is_deleted? %>
Deleted
<% end %>
<% if post.is_flagged? %>
Flagged Flagged
<% elsif post.is_appealed? %>
Appealed
<% elsif post.is_deleted? %>
Deleted
<% else %>
Active
<% end %> <% end %>
<% if post.is_banned? %> <% if post.is_banned? %>
Banned Banned
<% end %> <% end %>
<% if !post.is_pending? && !post.is_deleted? && !post.is_banned? %>
Active
<% end %>
</li> </li>
</ul> </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"> <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> <p>This post was flagged for review (<%= link_to_wiki "learn more", "howto:flag" %>): </p>
@@ -30,13 +30,14 @@
</div> </div>
<% end %> <% end %>
<% if post.is_pending? || post.is_flagged? %> <% if post.in_modqueue? %>
<div class="notice notice-small post-notice post-notice-pending"> <div class="notice notice-small post-notice post-notice-pending">
<% if post.is_pending? %> <% if post.is_pending? %>
This post is pending approval. This post is pending approval. (<%= link_to_wiki "learn more", "about:mod_queue" %>)
(<%= link_to_wiki "learn more", "about:mod_queue" %>) <% elsif post.is_flagged? %>
<% else %>
This post was flagged and is pending approval (<%= link_to_wiki "learn more", "about:mod_queue" %>) 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 %> <% end %>
<%= render "post_disapprovals/counts", :disapprovals => post.disapprovals, :post => post %> <%= render "post_disapprovals/counts", :disapprovals => post.disapprovals, :post => post %>
@@ -48,7 +49,8 @@
</div> </div>
<% end %> <% 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"> <div class="notice notice-small post-notice post-notice-appealed">
<p>This post was appealed:</p> <p>This post was appealed:</p>
<%= render "post_appeals/reasons", appeals: post.appeals %> <%= render "post_appeals/reasons", appeals: post.appeals %>

View File

@@ -51,25 +51,24 @@
<% if post.is_status_locked? %> <% if post.is_status_locked? %>
<li id="post-option-status-locked">Status locked</li> <li id="post-option-status-locked">Status locked</li>
<% else %> <% 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> <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> <li id="post-option-appeal"><%= link_to "Appeal", new_post_appeal_path(post_appeal: { post_id: post.id }), remote: true %></li>
<% end %> <% end %>
<% if policy(PostApproval).create? %> <% if policy(PostApproval).create? %>
<% if post.is_deleted? %> <% if post.is_approvable? %>
<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> <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>
<% if policy(post).move_favorites? %> <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>
<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>
<% end %> <% end %>
<% if post.is_approvable? && !post.is_deleted? %> <% if post.is_deleted? && policy(post).move_favorites? %>
<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-move-favorites"><%= link_to "Move favorites", confirm_move_favorites_moderator_post_post_path(post_id: post.id) %></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> <% end %>
<% if !post.is_deleted? && policy(post).delete? %>
<li id="post-option-delete"><%= link_to "Delete", post, method: :delete, remote: true %></li>
<% end %> <% end %>
<% if policy(post).unban? %> <% if policy(post).unban? %>

View File

@@ -0,0 +1,9 @@
class AddStatusToPostAppealsAndFlags < ActiveRecord::Migration[6.0]
def change
add_column :post_appeals, :status, :integer, default: 0, null: false
add_index :post_appeals, :status
add_column :post_flags, :status, :integer, default: 0, null: false
add_index :post_flags, :status
end
end

View File

@@ -2584,7 +2584,8 @@ CREATE TABLE public.post_appeals (
creator_id integer NOT NULL, creator_id integer NOT NULL,
reason text, reason text,
created_at timestamp without time zone NOT NULL, created_at timestamp without time zone NOT NULL,
updated_at timestamp without time zone NOT NULL updated_at timestamp without time zone NOT NULL,
status integer DEFAULT 0 NOT NULL
); );
@@ -2684,7 +2685,8 @@ CREATE TABLE public.post_flags (
reason text, reason text,
is_resolved boolean DEFAULT false NOT NULL, is_resolved boolean DEFAULT false NOT NULL,
created_at timestamp without time zone NOT NULL, created_at timestamp without time zone NOT NULL,
updated_at timestamp without time zone NOT NULL updated_at timestamp without time zone NOT NULL,
status integer DEFAULT 0 NOT NULL
); );
@@ -6622,6 +6624,13 @@ CREATE INDEX index_post_appeals_on_post_id ON public.post_appeals USING btree (p
CREATE INDEX index_post_appeals_on_reason_tsvector ON public.post_appeals USING gin (to_tsvector('english'::regconfig, reason)); CREATE INDEX index_post_appeals_on_reason_tsvector ON public.post_appeals USING gin (to_tsvector('english'::regconfig, reason));
--
-- Name: index_post_appeals_on_status; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX index_post_appeals_on_status ON public.post_appeals USING btree (status);
-- --
-- Name: index_post_approvals_on_post_id; Type: INDEX; Schema: public; Owner: - -- Name: index_post_approvals_on_post_id; Type: INDEX; Schema: public; Owner: -
-- --
@@ -6671,6 +6680,13 @@ CREATE INDEX index_post_flags_on_post_id ON public.post_flags USING btree (post_
CREATE INDEX index_post_flags_on_reason_tsvector ON public.post_flags USING gin (to_tsvector('english'::regconfig, reason)); CREATE INDEX index_post_flags_on_reason_tsvector ON public.post_flags USING gin (to_tsvector('english'::regconfig, reason));
--
-- Name: index_post_flags_on_status; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX index_post_flags_on_status ON public.post_flags USING btree (status);
-- --
-- Name: index_post_replacements_on_creator_id; Type: INDEX; Schema: public; Owner: - -- Name: index_post_replacements_on_creator_id; Type: INDEX; Schema: public; Owner: -
-- --
@@ -7393,6 +7409,7 @@ INSERT INTO "schema_migrations" (version) VALUES
('20200325074859'), ('20200325074859'),
('20200403210353'), ('20200403210353'),
('20200406054838'), ('20200406054838'),
('20200427190519'); ('20200427190519'),
('20200803022359');

View File

@@ -0,0 +1,26 @@
#!/usr/bin/env ruby
require_relative "../../config/environment"
PostFlag.transaction do
# Mark all old flags and appeals as succeeded or rejected. Recent flags and
# appeals are left as pending. This is not strictly correct for posts that
# may have been flagged or appealed multiple times.
PostAppeal.expired.where(post: Post.undeleted).update_all(status: "succeeded")
PostAppeal.expired.where(post: Post.deleted).update_all(status: "rejected")
PostFlag.where(post: Post.undeleted).update_all(status: "rejected")
PostFlag.where(post: Post.deleted).update_all(status: "succeeded")
# Mark all unapproved in three days flags as successful.
PostFlag.category_matches("deleted").update_all(status: "succeeded")
# Mark all currently flagged posts as pending.
PostFlag.where(post: Post.flagged).update_all(status: "pending")
puts "Appeals pending: #{PostAppeal.pending.count}"
puts "Appeals succeeded: #{PostAppeal.succeeded.count}"
puts "Appeals rejected: #{PostAppeal.rejected.count}"
puts "Flags pending: #{PostFlag.pending.count}"
puts "Flags succeeded: #{PostFlag.succeeded.count}"
puts "Flags rejected: #{PostFlag.rejected.count}"
end

View File

@@ -1,7 +1,7 @@
FactoryBot.define do FactoryBot.define do
factory(:post_flag) do factory(:post_flag) do
creator creator
post post { build(:post, is_flagged: true) }
reason {"xxx"} reason {"xxx"}
is_resolved {false} is_resolved {false}
end end

View File

@@ -13,6 +13,14 @@ class ModqueueControllerTest < ActionDispatch::IntegrationTest
get_auth modqueue_index_path, @admin get_auth modqueue_index_path, @admin
assert_response :success assert_response :success
end end
should "include appealed posts in the modqueue" do
@appeal = create(:post_appeal)
get_auth modqueue_index_path, @admin
assert_response :success
assert_select "#post-#{@appeal.post_id}"
end
end end
end end
end end

View File

@@ -48,16 +48,56 @@ class PostAppealsControllerTest < ActionDispatch::IntegrationTest
end end
context "create action" do context "create action" do
setup do context "appealing a deleted post" do
@post = as(@user) { create(:post, is_deleted: true) } should "create a new appeal" do
@post = create(:post, is_deleted: true)
assert_difference("PostAppeal.count", 1) do
post_auth post_appeals_path, @user, params: { post_appeal: { post_id: @post.id, reason: "xxx" }}, as: :json
end end
should "create a new appeal" do
assert_difference("PostAppeal.count", 1) do
post_auth post_appeals_path, @user, params: {:format => "js", :post_appeal => {:post_id => @post.id, :reason => "xxx"}}
assert_response :success assert_response :success
end end
end end
context "appealing a flagged post" do
should "fail" do
@flag = create(:post_flag)
assert_no_difference("PostAppeal.count") do
post_auth post_appeals_path, @user, params: { post_appeal: { post_id: @flag.post.id, reason: "xxx" }}, as: :json
end
assert_response 422
assert_equal(["cannot be appealed"], response.parsed_body.dig("errors", "post"))
end
end
context "appealing a pending post" do
should "fail" do
@post = create(:post, is_pending: true)
assert_no_difference("PostAppeal.count") do
post_auth post_appeals_path, @user, params: { post_appeal: { post_id: @post.id, reason: "xxx" }}, as: :json
end
assert_response 422
assert_equal(["cannot be appealed"], response.parsed_body.dig("errors", "post"))
end
end
context "appealing an already appealed post" do
should "fail" do
@appeal = create(:post_appeal)
assert_no_difference("PostAppeal.count") do
post_auth post_appeals_path, @user, params: { post_appeal: { post_id: @appeal.post.id, reason: "xxx" }}, as: :json
end
assert_response 422
assert_equal(["cannot be appealed"], response.parsed_body.dig("errors", "post"))
end
end
end end
end end
end end

View File

@@ -27,6 +27,29 @@ class PostApprovalsControllerTest < ActionDispatch::IntegrationTest
end end
end end
context "for an appealed post" do
should "undelete the post and mark the appeal as successful" do
@appeal = create(:post_appeal)
post_auth post_approvals_path(post_id: @appeal.post_id, format: :js), @approver
assert_response :success
assert_equal(false, @appeal.reload.post.is_deleted?)
assert_equal(true, @appeal.succeeded?)
end
end
context "for a flagged post" do
should "approve the post and mark the flag as rejected" do
@flag = create(:post_flag)
post_auth post_approvals_path(post_id: @flag.post_id, format: :js), @approver
assert_response :success
assert_equal(false, @flag.reload.post.is_deleted?)
assert_equal(false, @flag.post.is_flagged?)
assert_equal(true, @flag.rejected?)
end
end
should "not allow non-approvers to approve posts" do should "not allow non-approvers to approve posts" do
@post = create(:post, is_pending: true) @post = create(:post, is_pending: true)
post_auth post_approvals_path(post_id: @post.id, format: :js), create(:user) post_auth post_approvals_path(post_id: @post.id, format: :js), create(:user)

View File

@@ -10,6 +10,7 @@ class PostEventsControllerTest < ActionDispatch::IntegrationTest
as(@user) do as(@user) do
@post = create(:post) @post = create(:post)
@post.flag!("aaa") @post.flag!("aaa")
@post.update(is_deleted: true)
create(:post_appeal, post: @post) create(:post_appeal, post: @post)
@post.approve!(@mod) @post.approve!(@mod)
end end

View File

@@ -483,7 +483,7 @@ class PostsControllerTest < ActionDispatch::IntegrationTest
create(:note, post: @post) create(:note, post: @post)
create(:artist_commentary, post: @post) create(:artist_commentary, post: @post)
create(:post_flag, post: @post, creator: @user) create(:post_flag, post: @post, creator: @user)
create(:post_appeal, post: @post, creator: @user) #create(:post_appeal, post: @post, creator: @user)
create(:post_vote, post: @post, user: @user) create(:post_vote, post: @post, user: @user)
create(:favorite, post: @post, user: @user) create(:favorite, post: @post, user: @user)
create(:moderation_report, model: @comment, creator: @builder) create(:moderation_report, model: @comment, creator: @builder)
@@ -667,7 +667,6 @@ class PostsControllerTest < ActionDispatch::IntegrationTest
assert_response :success assert_response :success
assert_equal(false, @post.is_deleted?) assert_equal(false, @post.is_deleted?)
end end
end end
context "revert action" do context "revert action" do

View File

@@ -32,7 +32,7 @@ class PostAppealTest < ActiveSupport::TestCase
@post_appeal = build(:post_appeal, post: @post, creator: @alice) @post_appeal = build(:post_appeal, post: @post, creator: @alice)
assert_equal(false, @post_appeal.valid?) assert_equal(false, @post_appeal.valid?)
assert_equal(["Post is active"], @post_appeal.errors.full_messages) assert_equal(["Post cannot be appealed"], @post_appeal.errors.full_messages)
end end
end end
end end

View File

@@ -5,6 +5,7 @@ class PostEventTest < ActiveSupport::TestCase
@user = create(:user, created_at: 2.weeks.ago) @user = create(:user, created_at: 2.weeks.ago)
@post = create(:post) @post = create(:post)
@post_flag = create(:post_flag, creator: @user, post: @post) @post_flag = create(:post_flag, creator: @user, post: @post)
@post.update(is_deleted: true)
@post_appeal = create(:post_appeal, creator: @user, post: @post) @post_appeal = create(:post_appeal, creator: @user, post: @post)
end end

View File

@@ -53,7 +53,7 @@ class PostFlagTest < ActiveSupport::TestCase
@post_flag = build(:post_flag, post: @post, creator: @bob) @post_flag = build(:post_flag, post: @post, creator: @bob)
@post_flag.save @post_flag.save
assert_equal(["Post is deleted"], @post_flag.errors.full_messages) assert_equal(["Post is deleted and cannot be flagged"], @post_flag.errors.full_messages)
end end
should "not be able to flag a pending post" do should "not be able to flag a pending post" do

View File

@@ -1,30 +1,108 @@
require 'test_helper' require 'test_helper'
class PostPrunerTest < ActiveSupport::TestCase class PostPrunerTest < ActiveSupport::TestCase
def setup context "PostPruner" do
@old_post = FactoryBot.create(:post, :created_at => 5.days.ago, :is_pending => true) context "for a pending post" do
@unresolved_flagged_post = FactoryBot.create(:post, :is_flagged => true) should "prune expired posts" do
@resolved_flagged_post = FactoryBot.create(:post, :is_flagged => true) @post = create(:post, created_at: 5.days.ago, is_pending: true)
PostPruner.prune!
@flagger = create(:gold_user, created_at: 2.weeks.ago) assert_equal(true, @post.reload.is_deleted?)
@unresolved_post_flag = create(:post_flag, creator: @flagger, created_at: 5.days.ago, is_resolved: false, post: @unresolved_flagged_post) assert_equal(false, @post.is_pending?)
@resolved_post_flag = create(:post_flag, creator: @flagger, created_at: 5.days.ago, is_resolved: true, post: @resolved_flagged_post)
PostPruner.new.prune! assert_equal(1, @post.flags.size)
assert_equal("Unapproved in three days", @post.flags.last.reason)
end
end end
should "prune old pending posts" do context "for a flagged post" do
@old_post.reload should "prune expired flags" do
assert(@old_post.is_deleted?) @post = create(:post, created_at: 4.weeks.ago, is_flagged: true)
@flag = create(:post_flag, post: @post, created_at: 5.days.ago)
PostPruner.prune!
assert_equal(true, @post.reload.is_deleted?)
assert_equal(false, @post.is_pending?)
assert_equal(false, @post.is_flagged?)
assert_equal(true, @flag.reload.succeeded?)
assert_equal(2, @post.flags.size)
assert_equal("Unapproved in three days after returning to moderation queue", @post.flags.last.reason)
end end
should "prune old flagged posts that are still unresolved" do should "not prune unexpired flags" do
@unresolved_flagged_post.reload @post = create(:post, created_at: 4.weeks.ago, is_flagged: true)
assert(@unresolved_flagged_post.is_deleted?) @flag = create(:post_flag, post: @post, created_at: 1.day.ago)
PostPruner.prune!
assert_equal(false, @post.reload.is_deleted?)
assert_equal(false, @post.is_pending?)
assert_equal(true, @post.is_flagged?)
assert_equal(true, @flag.reload.pending?)
assert_equal(1, @post.flags.size)
end end
should "not prune old flagged posts that are resolved" do should "leave the status of old flags unchanged" do
@resolved_flagged_post.reload @post = create(:post, created_at: 4.weeks.ago, is_flagged: true)
assert(!@resolved_flagged_post.is_deleted?) @flag1 = create(:post_flag, post: @post, created_at: 3.weeks.ago, status: :succeeded)
@flag2 = create(:post_flag, post: @post, created_at: 2.weeks.ago, status: :rejected)
@flag3 = create(:post_flag, post: @post, created_at: 1.weeks.ago, status: :pending)
PostPruner.prune!
assert_equal(true, @post.reload.is_deleted?)
assert_equal(false, @post.is_pending?)
assert_equal(false, @post.is_flagged?)
assert_equal(true, @flag1.reload.succeeded?)
assert_equal(true, @flag2.reload.rejected?)
assert_equal(true, @flag3.reload.succeeded?)
end
end
context "for an appealed post" do
should "prune expired appeals" do
@post = create(:post, created_at: 4.weeks.ago, is_deleted: true)
@appeal = create(:post_appeal, post: @post, created_at: 5.days.ago)
PostPruner.prune!
assert_equal(false, @post.reload.is_pending?)
assert_equal(false, @post.is_flagged?)
assert_equal(true, @post.is_deleted?)
assert_equal(true, @appeal.reload.rejected?)
assert_equal(1, @post.flags.size)
assert_equal("Unapproved in three days after returning to moderation queue", @post.flags.last.reason)
end
should "not prune unexpired appeals" do
@post = create(:post, created_at: 4.weeks.ago, is_deleted: true)
@appeal = create(:post_appeal, post: @post, created_at: 1.day.ago)
PostPruner.prune!
assert_equal(false, @post.reload.is_pending?)
assert_equal(false, @post.is_flagged?)
assert_equal(true, @post.is_deleted?)
assert_equal(true, @appeal.reload.pending?)
assert_equal(0, @post.flags.size)
end
should "leave the status of old appeals unchanged" do
@post = create(:post, created_at: 4.weeks.ago, is_deleted: true)
@appeal1 = create(:post_appeal, post: @post, created_at: 3.weeks.ago, status: :succeeded)
@appeal2 = create(:post_appeal, post: @post, created_at: 2.weeks.ago, status: :rejected)
@appeal3 = create(:post_appeal, post: @post, created_at: 1.weeks.ago, status: :pending)
PostPruner.prune!
assert_equal(true, @post.reload.is_deleted?)
assert_equal(false, @post.is_pending?)
assert_equal(false, @post.is_flagged?)
assert_equal(true, @appeal1.reload.succeeded?)
assert_equal(true, @appeal2.reload.rejected?)
assert_equal(false, @appeal3.reload.pending?)
end
end
end end
end end

View File

@@ -584,22 +584,26 @@ class PostQueryBuilderTest < ActiveSupport::TestCase
flagged = create(:post, is_flagged: true) flagged = create(:post, is_flagged: true)
deleted = create(:post, is_deleted: true) deleted = create(:post, is_deleted: true)
banned = create(:post, is_banned: true) banned = create(:post, is_banned: true)
all = [banned, deleted, flagged, pending] appealed = create(:post, is_deleted: true)
appeal = create(:post_appeal, post: appealed)
all = [appealed, banned, deleted, flagged, pending]
assert_tag_match([flagged, pending], "status:modqueue") assert_tag_match([appealed, flagged, pending], "status:modqueue")
assert_tag_match([pending], "status:pending") assert_tag_match([pending], "status:pending")
assert_tag_match([flagged], "status:flagged") assert_tag_match([flagged], "status:flagged")
assert_tag_match([deleted], "status:deleted") assert_tag_match([appealed], "status:appealed")
assert_tag_match([appealed, deleted], "status:deleted")
assert_tag_match([banned], "status:banned") assert_tag_match([banned], "status:banned")
assert_tag_match([banned], "status:active") assert_tag_match([banned], "status:active")
assert_tag_match([banned], "status:active status:banned") assert_tag_match([banned], "status:active status:banned")
assert_tag_match(all, "status:any") assert_tag_match(all, "status:any")
assert_tag_match(all, "status:all") assert_tag_match(all, "status:all")
assert_tag_match(all - [flagged, pending], "-status:modqueue") assert_tag_match(all - [flagged, pending, appealed], "-status:modqueue")
assert_tag_match(all - [pending], "-status:pending") assert_tag_match(all - [pending], "-status:pending")
assert_tag_match(all - [flagged], "-status:flagged") assert_tag_match(all - [flagged], "-status:flagged")
assert_tag_match(all - [deleted], "-status:deleted") assert_tag_match(all - [appealed], "-status:appealed")
assert_tag_match(all - [deleted, appealed], "-status:deleted")
assert_tag_match(all - [banned], "-status:banned") assert_tag_match(all - [banned], "-status:banned")
assert_tag_match(all - [banned], "-status:active") assert_tag_match(all - [banned], "-status:active")
@@ -611,11 +615,13 @@ class PostQueryBuilderTest < ActiveSupport::TestCase
flagged = create(:post, is_flagged: true) flagged = create(:post, is_flagged: true)
pending = create(:post, is_pending: true) pending = create(:post, is_pending: true)
disapproved = create(:post, is_pending: true) disapproved = create(:post, is_pending: true)
appealed = create(:post, is_deleted: true)
create(:post_flag, post: flagged, creator: create(:user, created_at: 2.weeks.ago)) create(:post_flag, post: flagged, creator: create(:user, created_at: 2.weeks.ago))
create(:post_appeal, post: appealed)
create(:post_disapproval, user: CurrentUser.user, post: disapproved, reason: "disinterest") create(:post_disapproval, user: CurrentUser.user, post: disapproved, reason: "disinterest")
assert_tag_match([pending, flagged], "status:unmoderated") assert_tag_match([appealed, pending, flagged], "status:unmoderated")
assert_tag_match([disapproved], "-status:unmoderated") assert_tag_match([disapproved], "-status:unmoderated")
end end

View File

@@ -513,24 +513,23 @@ class PostTest < ActiveSupport::TestCase
end end
context "A status locked post" do context "A status locked post" do
setup do
@post = FactoryBot.create(:post, is_status_locked: true)
end
should "not allow new flags" do should "not allow new flags" do
assert_raises(PostFlag::Error) do assert_raises(PostFlag::Error) do
@post = create(:post, is_status_locked: true)
@post.flag!("wrong") @post.flag!("wrong")
end end
end end
should "not allow new appeals" do should "not allow new appeals" do
@post = create(:post, is_status_locked: true, is_deleted: true)
@appeal = build(:post_appeal, post: @post) @appeal = build(:post_appeal, post: @post)
assert_equal(false, @appeal.valid?) assert_equal(false, @appeal.valid?)
assert_equal(["Post is active"], @appeal.errors.full_messages) assert_equal(["Post cannot be appealed"], @appeal.errors.full_messages)
end end
should "not allow approval" do should "not allow approval" do
@post = create(:post, is_status_locked: true, is_pending: true)
approval = @post.approve! approval = @post.approve!
assert_includes(approval.errors.full_messages, "Post is locked and cannot be approved") assert_includes(approval.errors.full_messages, "Post is locked and cannot be approved")
end end

View File

@@ -12,7 +12,7 @@ class UploadLimitTest < ActiveSupport::TestCase
@post = create(:post, uploader: @user, is_pending: true, created_at: 7.days.ago) @post = create(:post, uploader: @user, is_pending: true, created_at: 7.days.ago)
assert_equal(1000, @user.reload.upload_points) assert_equal(1000, @user.reload.upload_points)
PostPruner.new.prune! PostPruner.prune!
assert_equal(967, @user.reload.upload_points) assert_equal(967, @user.reload.upload_points)
end end
end end