posts: rework post events page.

* Add a global /post_events page that shows the history of all approvals,
  disapprovals, flags, appeals, and replacements on a single page.

* Redesign the /posts/:id/events page to show all approval, disapproval,
  flag, appeal, and replacement events for a single post (before it only
  showed approvals, flags, and appeals).

* Remove the replacement history link from the post show page. Replacements
  are now included in the post events page (closes #4948: Highlighed replacements).

* Add /post_approvals/:id and /post_replacements/:id routes (these are
  used by the "Details" link on the post events page).
This commit is contained in:
evazion
2022-09-24 17:41:23 -05:00
parent fc122cbc5a
commit 361af6a4cb
26 changed files with 455 additions and 201 deletions

View File

@@ -15,4 +15,12 @@ class PostApprovalsController < ApplicationController
respond_with(@post_approvals)
end
def show
@approval = authorize PostApproval.find(params[:id])
respond_with(@approval) do |format|
format.html { redirect_to post_approvals_path(search: { id: @approval.id }) }
end
end
end

View File

@@ -4,7 +4,13 @@ class PostEventsController < ApplicationController
respond_to :html, :xml, :json
def index
@events = PostEvent.find_for_post(params[:post_id])
respond_with(@events)
if post_id = params[:post_id] || params.dig(:search, :post_id)
@post = Post.find(post_id)
end
@post_events = authorize PostEvent.paginated_search(params, defaults: { post_id: @post&.id }, count_pages: @post.present?)
@post_events = @post_events.includes(:creator, :post, model: [:post]) if request.format.html?
respond_with(@post_events)
end
end

View File

@@ -35,4 +35,12 @@ class PostReplacementsController < ApplicationController
respond_with(@post_replacements)
end
def show
@post_replacement = authorize PostReplacement.find(params[:id])
respond_with(@post_replacement) do |format|
format.html { redirect_to post_replacements_path(search: { id: @post_replacement.id }) }
end
end
end

View File

@@ -41,7 +41,7 @@ class BigqueryExportService
Rails.application.eager_load!
models = ApplicationRecord.descendants.sort_by(&:name)
models -= [GoodJob::BaseRecord, GoodJob::Process, GoodJob::Execution, GoodJob::ActiveJobJob, GoodJob::Job, GoodJob::Setting, TagRelationship, ArtistVersion, ArtistCommentaryVersion, NoteVersion, PoolVersion, PostVersion, WikiPageVersion, Post, PostVote, MediaAsset, Favorite, AITag]
models -= [GoodJob::BaseRecord, GoodJob::Process, GoodJob::Execution, GoodJob::ActiveJobJob, GoodJob::Job, GoodJob::Setting, TagRelationship, ArtistVersion, ArtistCommentaryVersion, NoteVersion, PoolVersion, PostVersion, WikiPageVersion, Post, PostEvent, PostVote, MediaAsset, Favorite, AITag, UserAction]
models
end

View File

@@ -185,6 +185,10 @@ module Source
nil
end
def self.site_name(url)
Source::URL.parse(url)&.site_name
end
def self.image_url?(url)
Source::URL.parse(url)&.image_url?
end

View File

@@ -70,6 +70,7 @@ class Post < ApplicationRecord
has_many :favorites, dependent: :destroy
has_many :replacements, class_name: "PostReplacement", :dependent => :destroy
has_many :ai_tags, through: :media_asset
has_many :events, class_name: "PostEvent"
attr_accessor :old_tag_string, :old_parent_id, :old_source, :old_rating, :has_constraints, :disable_versioning, :post_edit
@@ -1631,7 +1632,7 @@ class Post < ApplicationRecord
def self.available_includes
# attributes accessible through the ?only= parameter
%i[
uploader approver flags appeals parent children notes
uploader approver flags appeals events parent children notes
comments approvals disapprovals replacements pixiv_ugoira_frame_data
artist_commentary media_asset ai_tags
]

View File

@@ -1,82 +1,40 @@
# frozen_string_literal: true
class PostEvent
include ActiveModel::Model
include ActiveModel::Serializers::JSON
include ActiveModel::Serializers::Xml
class PostEvent < ApplicationRecord
belongs_to :model, polymorphic: true
belongs_to :creator, class_name: "User"
belongs_to :post
attr_accessor :event
delegate :created_at, to: :event
def self.find_for_post(post_id)
post = Post.find(post_id)
(post.appeals + post.flags + post.approvals).sort_by(&:created_at).reverse.map { |e| new(event: e) }
def self.model_types
%w[Post PostAppeal PostApproval PostDisapproval PostFlag PostReplacement]
end
def type_name
case event
when PostFlag
"flag"
when PostAppeal
"appeal"
when PostApproval
"approval"
end
def self.visible(user)
all
end
def type
type_name.first
end
def self.search(params, current_user)
q = search_attributes(params, [:model, :post, :creator, :event_at], current_user: current_user)
def reason
event.try(:reason) || ""
end
def creator_id
event.try(:creator_id) || event.try(:user_id)
end
def creator
event.try(:creator) || event.try(:user)
end
def status
if event.is_a?(PostApproval)
"approved"
elsif (event.is_a?(PostAppeal) && event.succeeded?) || (event.is_a?(PostFlag) && event.rejected?)
"approved"
elsif (event.is_a?(PostAppeal) && event.rejected?) || (event.is_a?(PostFlag) && event.succeeded?)
"deleted"
case params[:order]
when "event_at_asc"
q = q.order(event_at: :asc, model_id: :asc)
else
"pending"
q = q.apply_default_order(params)
end
q
end
def is_creator_visible?(user = CurrentUser.user)
case event
when PostAppeal, PostApproval
true
when PostFlag
flag = event
Pundit.policy!(user, flag).can_view_flagger?
end
def self.default_order
order(event_at: :desc, model_id: :desc)
end
def attributes
{
creator_id: nil,
created_at: nil,
reason: nil,
status: nil,
type: nil,
}
def self.available_includes
[:post, :model] # XXX creator isn't included because it leaks flagger/disapprover names
end
# XXX can't use hidden_attributes because we don't inherit from ApplicationRecord.
def serializable_hash(options = {})
hash = super
hash = hash.except(:creator_id) unless is_creator_visible?
hash
def readonly?
true
end
end

View File

@@ -115,6 +115,7 @@ class User < ApplicationRecord
has_many :post_appeals, foreign_key: :creator_id
has_many :post_approvals, :dependent => :destroy
has_many :post_disapprovals, :dependent => :destroy
has_many :post_events, class_name: "PostEvent", foreign_key: :creator_id
has_many :post_flags, foreign_key: :creator_id
has_many :post_votes
has_many :post_versions, foreign_key: :updater_id

View File

@@ -0,0 +1,36 @@
# frozen_string_literal: true
class PostEventPolicy < ApplicationPolicy
def can_see_creator?
case event.model_type
when "PostFlag"
policy(event.model).can_view_flagger?
when "PostDisapproval"
policy(event.model).can_view_creator?
else
true
end
end
def api_attributes
[:model_type, :model_id, :post_id, (:creator_id if can_see_creator?), :event_at].compact
end
def visible_for_search(events, attribute)
case attribute
in :creator | :creator_id
events.model_types.map do |type|
attr = attribute
attr = attr.to_s.gsub("creator", "uploader").to_sym if type == "Post"
attr = attr.to_s.gsub("creator", "user").to_sym if type in "PostApproval" | "PostDisapproval"
# XXX ordering by created_at desc is a query planner hack to make Postgres use the right indexes.
events.where(model: type.constantize.visible_for_search(attr, user).order(created_at: :desc))
end.reduce(:or)
else
events
end
end
alias_method :event, :record
end

View File

@@ -43,3 +43,5 @@
<%= numbered_paginator(@post_appeals) %>
</div>
</div>
<%= render "post_events/secondary_links" %>

View File

@@ -23,3 +23,5 @@
<%= numbered_paginator(@post_approvals) %>
</div>
</div>
<%= render "post_events/secondary_links" %>

View File

@@ -39,3 +39,5 @@
<%= numbered_paginator(@post_disapprovals) %>
</div>
</div>
<%= render "post_events/secondary_links" %>

View File

@@ -0,0 +1,17 @@
<% content_for(:secondary_links) do %>
<% case controller_name %>
<% when "post_approvals" %>
<%= quick_search_form_for(:user_name, post_approvals_path, "user", autocomplete: "user") %>
<% when "post_disapprovals" %>
<%= quick_search_form_for(:user_name, post_disapprovals_path, "user", autocomplete: "user") %>
<% else %>
<%= quick_search_form_for(:creator_name, url_for, "user", autocomplete: "user") %>
<% end %>
<%= subnav_link_to "Events", post_events_path %>
<%= subnav_link_to "Appeals", post_appeals_path %>
<%= subnav_link_to "Approvals", post_approvals_path %>
<%= subnav_link_to "Disapprovals", post_disapprovals_path %>
<%= subnav_link_to "Flags", post_flags_path %>
<%= subnav_link_to "Replacements", post_replacements_path %>
<% end %>

View File

@@ -1,25 +1,106 @@
<div id="c-post-events">
<div id="a-index">
<h1>Post Events</h1>
<% if @post %>
<h1>Events: <%= link_to @post.dtext_shortlink, @post %></h1>
<%= link_to "« Back", post_events_path, class: "text-xs" %>
<% else %>
<h1>Events</h1>
<% end %>
<%= table_for @events, class: "striped autofit", width: "100%" do |t| %>
<% t.column :type_name, name: "Type" %>
<% t.column "Description", td: { class: "col-expand" } do |event| %>
<div class="prose">
<%= format_text event.reason %>
</div>
<% unless @post %>
<%= search_form_for(post_events_path) do |f| %>
<%= f.input :creator_name, label: "User", input_html: { value: params[:search][:creator_name], "data-autocomplete": "user" } %>
<%= f.input :post_tags_match, label: "Tags", input_html: { value: params[:search][:post_tags_match], "data-autocomplete": "tag-query" } %>
<%= f.input :model_type, label: "Category", collection: PostEvent.model_types.map { |type| [type.titleize.delete_prefix("Post "), type] }, include_blank: true, selected: params[:search][:model_type] %>
<%= f.input :order, collection: [%w[Newest event_at], %w[Oldest event_at_asc]], include_blank: true, selected: params[:search][:order] %>
<%= f.submit "Search" %>
<% end %>
<% t.column "Status" do |event| %>
<%= event.status %>
<% end %>
<%= table_for @post_events, class: "striped autofit mt-4", width: "100%" do |t| %>
<% t.column "Event", td: { class: "col-expand" } do |event| %>
<% post = event.post %>
<% model = event.model %>
<% creator = event.creator %>
<% case event.model_type %>
<% when "Post" %>
<%= link_to post.dtext_shortlink, post %> was uploaded by <%= link_to_user creator %>.
<% when "PostAppeal" %>
<div class="prose">
<%= link_to post.dtext_shortlink, post %> was appealed by <%= link_to_user creator %><%= " (#{format_text(model.reason.strip.chomp("."), inline: true)})".html_safe if model.reason.present? %>.
</div>
<% when "PostApproval" %>
<%= link_to post.dtext_shortlink, post %> was approved by <%= link_to_user creator %>.
<% when "PostDisapproval" %>
<div class="prose">
<% if policy(model).can_view_creator? %>
<%= link_to post.dtext_shortlink, post %> was disapproved by <%= link_to_user creator %> (<%= model.reason.titleize.downcase %><%= ": ".html_safe + format_text(model.message.strip.chomp("."), inline: true) if model.message.present? %>).
<% else %>
<%= link_to post.dtext_shortlink, post %> was disapproved (<%= model.reason.titleize.downcase %><%= ": ".html_safe + format_text(model.message.strip.chomp("."), inline: true) if model.message.present? %>).
<% end %>
</div>
<% when "PostFlag" %>
<div class="prose">
<% if policy(model).can_view_flagger? %>
<%= link_to post.dtext_shortlink, post %> was flagged by <%= link_to_user creator %> (<%= format_text(model.reason.strip.chomp("."), inline: true) %>).
<% else %>
<%= link_to post.dtext_shortlink, post %> was flagged (<%= format_text(model.reason.strip.chomp("."), inline: true) %>).
<% end %>
</div>
<% when "PostReplacement" %>
<% if model.old_file_size && model.old_file_ext && model.old_image_width && model.old_image_height && model.file_size && model.file_ext && model.image_width && model.image_height %>
<%= link_to post.dtext_shortlink, post %> was replaced by <%= link_to_user creator %>
(<%= external_link_to model.original_url.presence || "none", Source::URL.site_name(model.original_url) || model.original_url %>,
<%= model.old_file_size.to_formatted_s(:human_size, precision: 4) %> .<%= model.old_file_ext %>, <%= model.old_image_width %>x<%= model.old_image_height %> ->
<%= external_link_to model.replacement_url, Source::URL.site_name(model.replacement_url) || model.replacement_url %>,
<%= model.file_size.to_formatted_s(:human_size, precision: 4) %> .<%= model.file_ext %>, <%= model.image_width %>x<%= model.image_height %>).
<% else %>
<%= link_to post.dtext_shortlink, post %> was replaced by <%= link_to_user creator %>
(<%= external_link_to model.original_url.presence || "none", Source::URL.site_name(model.original_url) || model.original_url %> ->
<%= external_link_to model.replacement_url, Source::URL.site_name(model.replacement_url) || model.replacement_url %>).
<% end %>
<% end %>
<% end %>
<% t.column "Category" do |event| %>
<%= link_to event.model_type.titleize.delete_prefix("Post "), post_events_path(search: { model_type: event.model_type, **search_params }) %>
<% end %>
<% t.column "User" do |event| %>
<% if event.is_creator_visible? %>
<%= link_to_user event.creator %>
<% if policy(event).can_see_creator? %>
<%= link_to_user event.creator %> <%= link_to "»", post_events_path(search: { **search_params, creator_name: event.creator.name }) %>
<% else %>
<i>hidden</i>
<% end %>
<br><%= time_ago_in_words_tagged event.created_at %>
<div><%= time_ago_in_words_tagged(event.event_at) %></div>
<% end %>
<% t.column column: "control" do |event| %>
<%= render PopupMenuComponent.new do |menu| %>
<% unless @post %>
<% menu.item do %>
<%= link_to "Post history", post_post_events_path(event.post) %>
<% end %>
<% end %>
<% if policy(event).can_see_creator? %>
<% menu.item do %>
<%= link_to "User history", post_events_path(search: { creator_name: event.creator.name }) %>
<% end %>
<% end %>
<% if policy(event).can_see_creator? %>
<% menu.item do %>
<%= link_to "Details", event.model %>
<% end %>
<% end %>
<% end %>
<% end %>
<% end %>
<%= numbered_paginator(@post_events) %>
</div>
</div>
<%= render "secondary_links" %>

View File

@@ -48,3 +48,5 @@
<%= numbered_paginator(@post_flags) %>
</div>
</div>
<%= render "post_events/secondary_links" %>

View File

@@ -66,3 +66,5 @@
<%= numbered_paginator(@post_replacements) %>
</div>
</div>
<%= render "post_events/secondary_links" %>

View File

@@ -188,9 +188,8 @@
<li id="post-history-tags"><%= link_to "Tags", post_versions_path(search: { post_id: @post.id }) %></li>
<li id="post-history-pools"><%= link_to "Pools", pool_versions_path(search: { post_id: @post.id }) %></li>
<li id="post-history-notes"><%= link_to "Notes", note_versions_path(search: { post_id: @post.id }) %></li>
<li id="post-history-moderation"><%= link_to "Moderation", post_events_path(@post.id) %></li>
<li id="post-history-moderation"><%= link_to "Moderation", post_post_events_path(@post.id) %></li>
<li id="post-history-commentary"><%= link_to "Commentary", artist_commentary_versions_path(search: { post_id: @post.id }) %></li>
<li id="post-history-replacements"><%= link_to "Replacements", post_replacements_path(search: {post_id: @post.id }) %></li>
</ul>
</section>
<% end %>

View File

@@ -18,9 +18,10 @@
<ul>
<li><h2>Post Events</h2></li>
<li><%= link_to("Tag History", post_versions_path) %></li>
<li><%= link_to("Events", post_events_path) %></li>
<li><%= link_to("Appeals", post_appeals_path) %></li>
<li><%= link_to("Approvals", post_approvals_path) %></li>
<li><%= link_to("Disapprovals", post_disapprovals_path) %></li>
<li><%= link_to("Appeals", post_appeals_path) %></li>
<li><%= link_to("Flags", post_flags_path) %></li>
<li><%= link_to("Replacements", post_replacements_path) %></li>
</ul>