Add /user_actions page.

Add a /user_actions page. This page shows you a global timeline of
(almost) all activity on the site, including uploads, comments, votes,
edits, forum posts, and so on.

The main things it doesn't include are post edits, pool edits, and
favorites (posts and pools live in a separate database, and favorites
don't have the timestamps we need for ordering).

This page is useful for moderation purposes because it lets you see a
history of almost all of a user's activity on a single page.

Currently this page is mod-only. In the future it will be open to all
users, so you can view the history of your own site activity, or the
activity of others.
This commit is contained in:
evazion
2022-09-14 16:30:46 -05:00
parent 0830af49a7
commit ee638f976f
22 changed files with 462 additions and 7 deletions

View File

@@ -0,0 +1,23 @@
# frozen_string_literal: true
class UserActionsController < ApplicationController
respond_to :html, :xml, :json
def index
if user_id = params[:user_id] || params.dig(:search, :user_id)
@user = User.find(user_id)
elsif user_name = params.dig(:search, :user_name)
@user = User.find_by_name(user_name)
end
@user_actions = authorize UserAction.for_user(CurrentUser.user).paginated_search(params, count_pages: @user.present?)
@user_actions = @user_actions.includes(:user, model: [:artist, :post, :note, :user, :creator, :banner, :bulk_update_request, :tag, :antecedent_tag, :consequent_tag, :model, :topic, :purchaser, :recipient, :forum_topic, forum_post: [:topic], comment: [:creator, :post]]) if request.format.html?
respond_with(@user_actions)
end
def show
@user_actions = authorize UserAction.find(params[:id])
respond_with(@user_actions)
end
end

View File

@@ -199,7 +199,7 @@ module ApplicationHelper
end
def link_to_search(tag, **options)
link_to tag.name, posts_path(tags: tag.name), class: tag_class(tag), **options
link_to tag.pretty_name, posts_path(tags: tag.name), class: tag_class(tag), **options
end
def link_to_wiki(text, title = text, **options)

View File

@@ -234,6 +234,10 @@ class ApplicationRecord < ActiveRecord::Base
end
end
def revised?
updated_at > created_at
end
def warnings
@warnings ||= ActiveModel::Errors.new(self)
end

View File

@@ -69,6 +69,10 @@ class Ban < ApplicationRecord
ApplicationController.helpers.humanized_duration(duration)
end
def forever?
duration.present? && duration >= 100.years
end
def expired?
persisted? && expires_at < Time.zone.now
end

View File

@@ -76,7 +76,11 @@ class Dmail < ApplicationRecord
module SearchMethods
def visible(user)
where(owner: user)
if user.is_anonymous?
none
else
where(owner: user)
end
end
def sent_by(user)

View File

@@ -8,7 +8,7 @@ class Favorite < ApplicationRecord
after_create :upvote_post_on_create
after_destroy :unvote_post_on_destroy
scope :public_favorites, -> { where(user: User.has_public_favorites) }
scope :public_favorites, -> { where.not(user: User.has_private_favorites) }
def self.visible(user)
if user.is_admin?

View File

@@ -17,6 +17,9 @@ class FavoriteGroup < ApplicationRecord
array_attribute :post_ids, parse: /\d+/, cast: :to_i
scope :is_public, -> { where(is_public: true) }
scope :is_private, -> { where(is_public: false) }
module SearchMethods
def for_post(post_id)
where_array_includes_any(:post_ids, [post_id])
@@ -29,7 +32,13 @@ class FavoriteGroup < ApplicationRecord
end
def visible(user)
where(is_public: true).or(where(creator_id: user.id))
if user.is_owner?
all
elsif user.is_anonymous?
is_public
else
is_public.or(where(creator: user))
end
end
def search(params)

View File

@@ -3,6 +3,8 @@
class ForumPostVote < ApplicationRecord
belongs_to :creator, class_name: "User"
belongs_to :forum_post
belongs_to :bulk_update_request, primary_key: :forum_post_id, foreign_key: :forum_post_id, optional: true
validates :creator_id, uniqueness: {scope: :forum_post_id}
validates :score, inclusion: {in: [-1, 0, 1]}

View File

@@ -35,8 +35,10 @@ class ModerationReport < ApplicationRecord
def self.visible(user)
if user.is_moderator?
all
else
elsif !user.is_anonymous?
where(creator: user)
else
none
end
end

View File

@@ -17,7 +17,7 @@ class PostVote < ApplicationRecord
scope :positive, -> { where("post_votes.score > 0") }
scope :negative, -> { where("post_votes.score < 0") }
scope :public_votes, -> { active.positive.where(user: User.has_public_favorites) }
scope :public_votes, -> { active.positive.where.not(user: User.has_private_favorites) }
deletable

View File

@@ -18,7 +18,11 @@ class SavedSearch < ApplicationRecord
scope :has_tag, ->(name) { where_regex(:query, "(^| )[~-]?#{Regexp.escape(name)}( |$)", flags: "i") }
def self.visible(user)
where(user: user)
if user.is_anonymous?
none
else
where(user: user)
end
end
concerning :Redis do

View File

@@ -39,4 +39,8 @@ class TagVersion < ApplicationRecord
def category_name
TagCategory.reverse_mapping[category].capitalize
end
def pretty_name
name.tr("_", " ")
end
end

122
app/models/user_action.rb Normal file
View File

@@ -0,0 +1,122 @@
# frozen_string_literal: true
class UserAction < ApplicationRecord
belongs_to :model, polymorphic: true
belongs_to :user
attribute :model_type, :string
attribute :model_id, :integer
attribute :user_id, :integer
attribute :event_type, :string
attribute :event_at, :time
def self.model_types
%w[ArtistVersion ArtistCommentaryVersion Ban BulkUpdateRequest Comment
CommentVote Dmail FavoriteGroup ForumPost ForumPostVote ForumTopic
ModAction ModerationReport NoteVersion Post PostAppeal PostApproval
PostDisapproval PostFlag PostReplacement PostVote SavedSearch TagAlias
TagImplication TagVersion Upload User UserEvent UserFeedback UserUpgrade
UserNameChangeRequest WikiPageVersion]
end
def self.for_user(user)
sql = <<~SQL.squish
(#{ArtistVersion.visible(user).select("'ArtistVersion'::character varying AS model_type, id AS model_id, updater_id AS user_id, 'create'::character varying AS event_type, created_at AS event_at").to_sql})
UNION ALL
(#{ArtistCommentaryVersion.visible(user).select("'ArtistCommentaryVersion', id, updater_id, 'create', created_at").to_sql})
UNION ALL
(#{Ban.visible(user).select("'Ban', id, user_id, 'subject', created_at").to_sql})
UNION ALL
(#{BulkUpdateRequest.visible(user).select("'BulkUpdateRequest', id, user_id, 'create', created_at").to_sql})
UNION ALL
(#{Comment.visible(user).select("'Comment', id, creator_id, 'create', created_at").to_sql})
UNION ALL
(#{CommentVote.visible(user).select("'CommentVote', id, user_id, 'create', created_at").to_sql})
UNION ALL
(#{Dmail.visible(user).sent.select("'Dmail', id, from_id, 'create', created_at").order(created_at: :desc).to_sql})
UNION ALL
(#{FavoriteGroup.visible(user).select("'FavoriteGroup', id, creator_id, 'create', created_at").order(created_at: :desc).to_sql})
UNION ALL
(#{ForumPost.visible(user).select("'ForumPost', id, creator_id, 'create', created_at").order(created_at: :desc).to_sql})
UNION ALL
(#{ForumPostVote.visible(user).select("'ForumPostVote', id, creator_id, 'create', created_at").order(created_at: :desc).to_sql})
UNION ALL
(#{ForumTopic.visible(user).select("'ForumTopic', id, creator_id, 'create', created_at").to_sql})
UNION ALL
(#{ModAction.visible(user).select("'ModAction', id, creator_id, 'create', created_at").to_sql})
UNION ALL
(#{ModerationReport.visible(user).select("'ModerationReport', id, creator_id, 'create', created_at").to_sql})
UNION ALL
(#{NoteVersion.visible(user).select("'NoteVersion', id, updater_id, 'create', created_at").to_sql})
UNION ALL
(#{Post.visible(user).select("'Post', id, uploader_id, 'create', created_at").to_sql})
UNION ALL
(#{PostAppeal.visible(user).select("'PostAppeal', id, creator_id, 'create', created_at").to_sql})
UNION ALL
(#{PostApproval.visible(user).select("'PostApproval', id, user_id, 'create', created_at").to_sql})
UNION ALL
(#{PostDisapproval.visible(user).select("'PostDisapproval', id, user_id, 'create', created_at").to_sql})
UNION ALL
(#{PostFlag.visible(user).select("'PostFlag', id, creator_id, 'create', created_at").to_sql})
UNION ALL
(#{PostReplacement.visible(user).select("'PostReplacement', id, creator_id, 'create', created_at").to_sql})
UNION ALL
(#{PostVote.visible(user).select("'PostVote', id, user_id, 'create', created_at").order(created_at: :desc).to_sql})
UNION ALL
(#{SavedSearch.visible(user).select("'SavedSearch', id, user_id, 'create', created_at").order(created_at: :desc).to_sql})
UNION ALL
(#{TagAlias.visible(user).select("'TagAlias', id, creator_id, 'create', created_at").to_sql})
UNION ALL
(#{TagImplication.visible(user).select("'TagImplication', id, creator_id, 'create', created_at").to_sql})
UNION ALL
(#{TagVersion.visible(user).select("'TagVersion', id, updater_id, 'create', created_at").where("updater_id IS NOT NULL").order(created_at: :desc).to_sql})
UNION ALL
(#{Upload.visible(user).select("'Upload', id, uploader_id, 'create', created_at").order(created_at: :desc).to_sql})
UNION ALL
(#{User.visible(user).select("'User', id, id, 'create', created_at").to_sql})
UNION ALL
(#{UserEvent.visible(user).select("'UserEvent', id, user_id, 'create', created_at").to_sql})
UNION ALL
(#{UserFeedback.visible(user).select("'UserFeedback', id, creator_id, 'create', created_at").to_sql})
UNION ALL
(#{UserFeedback.visible(user).select("'UserFeedback', id, user_id, 'subject', created_at").to_sql})
UNION ALL (
(#{UserUpgrade.visible(user).select("'UserUpgrade', id, purchaser_id, 'create', created_at").where(status: [:complete, :refunded]).order(created_at: :desc).to_sql})
) UNION ALL
(#{UserNameChangeRequest.visible(user).select("'UserNameChangeRequest', id, user_id, 'create', created_at").to_sql})
UNION ALL
(#{WikiPageVersion.visible(user).select("'WikiPageVersion', id, updater_id, 'create', created_at").to_sql})
SQL
from("(#{sql}) user_actions")
end
def self.visible(user)
all
end
def self.search(params)
q = search_attributes(params, :event_type, :user, :model)
case params[:order]
when "event_at_asc"
q = q.order(event_at: :asc, model_id: :asc)
else
q = q.apply_default_order(params)
end
q
end
def self.default_order
order(event_at: :desc, model_id: :desc)
end
def self.available_includes
[:user, :model]
end
def readonly?
true
end
end

View File

@@ -21,6 +21,10 @@ class CommentPolicy < ApplicationPolicy
user.is_moderator?
end
def can_see_creator?
!record.is_deleted? || can_see_deleted?
end
def reply?
!record.is_deleted?
end

View File

@@ -0,0 +1,20 @@
# frozen_string_literal: true
class UserActionPolicy < ApplicationPolicy
def index?
user.is_moderator?
end
def can_see_user?
case record.model_type
when "Comment"
policy(record.model).can_see_creator?
when "PostFlag"
policy(record.model).can_view_flagger?
when "PostDisapproval"
policy(record.model).can_view_creator?
else
true
end
end
end

View File

@@ -145,6 +145,11 @@
<ul>
<li><h2>Admin</h2></li>
<li><%= link_to("Mod Actions", mod_actions_path) %></li>
<% if policy(UserAction).index? %>
<li><%= link_to("User Actions", user_actions_path) %></li>
<% end %>
<li><%= link_to("Bulk Update Requests", bulk_update_requests_path) %></li>
<% if policy(UserNameChangeRequest).index? %>

View File

@@ -0,0 +1,11 @@
<% content_for(:secondary_links) do %>
<%= quick_search_form_for(:user_name, user_actions_path, "users", autocomplete: "user") %>
<%= subnav_link_to "Mod Actions", mod_actions_path %>
<%= subnav_link_to "User Actions", user_actions_path %>
<%= subnav_link_to "User Events", user_events_path %>
<%= subnav_link_to "Bans", bans_path %>
<%= subnav_link_to "IP Bans", ip_bans_path %>
<%= subnav_link_to "Feedbacks", user_feedbacks_path %>
<%= subnav_link_to "Mod Reports", moderation_reports_path %>
<%= subnav_link_to "Modqueue", modqueue_index_path %>
<% end %>

View File

@@ -0,0 +1,161 @@
<div id="c-user-actions">
<div id="a-index">
<% if @user %>
<h1>User Actions: <%= link_to_user @user %></h1>
<%= link_to "« Back", user_actions_path, class: "text-xs" %>
<% else %>
<h1>User Actions</h1>
<% end %>
<%= search_form_for(user_actions_path) do |f| %>
<%= f.input :user_name, label: "User", input_html: { value: @user&.name, "data-autocomplete": "user" } %>
<%= f.input :model_type, label: "Category", collection: UserAction.model_types.map { |type| [type.delete_suffix("Version").titleize, 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 %>
<div>
<%= table_for @user_actions, class: "striped autofit", width: "100%" do |t| %>
<% t.column "Event", td: { class: "col-expand" } do |user_action| %>
<% model = user_action.model %>
<% user = user_action.user %>
<% event_type = user_action.event_type %>
<% case user_action.model_type %>
<% when "ArtistVersion" %>
<%= link_to_user user %> <%= link_to "updated", model %> the artist <%= link_to model.artist.pretty_name, model.artist, class: "tag-type-#{Tag.categories.artist}" %>.
<% when "ArtistCommentaryVersion" %>
<%= link_to_user user %> updated the <%= link_to "commentary", model %> for <%= link_to model.post.dtext_shortlink, model.post %>.
<% when "NoteVersion" %>
<%= link_to_user user %> <%= link_to "updated", model %> a <%= link_to "note", model.note %> on <%= link_to model.post.dtext_shortlink, model.post %>.
<% when "TagVersion" %>
<%= link_to_user user %> updated the tag <%= link_to model.tag.name, model, class: tag_class(model) %>.
<% when "WikiPageVersion" %>
<%= link_to_user user %> <%= link_to "updated", model %> the <%= link_to_wiki model.title %> wiki.
<% when "Ban" %>
<div class="prose">
<%= link_to_user user %> was <%= link_to (model.forever? ? "banned forever" : "banned for #{model.humanized_duration}"), model %> by <%= link_to_user model.banner %> (<%= format_text(model.reason.chomp(".").strip, inline: true) %>).
</div>
<% when "BulkUpdateRequest" %>
<%= link_to_user user %> created a <%= link_to "new BUR", model %> in <%= link_to model.forum_topic.title, model.forum_post %>.
<% when "Comment" %>
<% if model.is_deleted? && !policy(model).can_see_deleted? %>
[deleted] posted a <%= link_to "deleted comment", model %> on <%= link_to model.post.dtext_shortlink, model.post %>.
<% else %>
<%= link_to_user user %> <%= link_to "commented", model %> on <%= link_to model.post.dtext_shortlink, model.post %>.
<% end %>
<% when "CommentVote" %>
<%= link_to_user user %> <%= model.is_positive? ? "upvoted" : "downvoted" %> a <%= link_to "comment", model.comment %> by <%= link_to_user model.comment.creator %> on <%= link_to model.comment.post.dtext_shortlink, model.comment.post %>.
<% when "Dmail" %>
<%= link_to_user user %> sent a dmail to <%= link_to_user model.to %> (<%= link_to model.title.strip, model %>).
<% when "FavoriteGroup" %>
<%= link_to_user user %> create <%= model.is_public? ? "public" : "private" %> favgroup <%= link_to model.pretty_name, model %>.
<% when "ForumPost" %>
<%= link_to_user user %> <%= link_to "posted", model %> in topic <%= link_to model.topic.title.chomp(".").strip, model.topic %>.
<% when "ForumTopic" %>
<%= link_to_user user %> created topic "<%= link_to model.title.strip, model %>".
<% when "ForumPostVote" %>
<% if model&.bulk_update_request.present? %>
<%= link_to_user user %> <%= model.vote_type %>voted a <%= link_to "BUR", model.bulk_update_request %> in topic <%= link_to model.forum_post.topic.title.strip, model.forum_post %>.
<% else %>
<%= link_to_user user %> <%= model.vote_type %>voted a <%= link_to "post", model.forum_post %> in topic <%= link_to model.forum_post.topic.title.strip, model.forum_post %>.
<% end %>
<% when "ModAction" %>
<div class="prose">
<%= link_to_user user %> <%= format_text(model.description, inline: true) %>.
</div>
<% when "ModerationReport" %>
<div class="prose">
<%= link_to_user user %> <%= link_to "reported", model %> a <%= link_to model.model.class.name.titleize.downcase, model.model %> by <%= link_to_user model.reported_user %> (<%= format_text(model.reason.chomp(".").strip, inline: true) %>).
</div>
<% when "TagAlias" %>
<%= link_to_user user %> aliased <%= link_to_search model.antecedent_tag %> to <%= link_to_search model.consequent_tag %>.
<% when "TagImplication" %>
<%= link_to_user user %> implied <%= link_to_search model.antecedent_tag %> to <%= link_to_search model.consequent_tag %>.
<% when "Post" %>
<%= link_to_user user %> created <%= link_to model.dtext_shortlink, model %>.
<% when "PostAppeal" %>
<%= link_to_user user %> appealed <%= link_to model.post.dtext_shortlink, model.post %>.
<% when "PostApproval" %>
<%= link_to_user user %> approved <%= link_to model.post.dtext_shortlink, model.post %>.
<% when "PostDisapproval" %>
<div class="prose">
<% if policy(model).can_view_creator? %>
<%= link_to_user user %> disapproved <%= link_to model.post.dtext_shortlink, model.post %> (<%= model.reason.titleize.downcase %><%= ": ".html_safe + format_text(model.message.chomp(".").strip, inline: true) if model.message.present? %>).
<% else %>
<%= link_to model.post.dtext_shortlink, model.post %> was disapproved (<%= model.reason.titleize.downcase %><%= ": ".html_safe + format_text(model.message.chomp(".").strip, inline: true) if model.message.present? %>).
<% end %>
</div>
<% when "PostFlag" %>
<div class="prose">
<% if policy(model).can_view_flagger? %>
<%= link_to_user user %> flagged <%= link_to model.post.dtext_shortlink, model.post %> (<%= format_text(model.reason.chomp(".").strip, inline: true) %>).
<% else %>
<%= link_to model.post.dtext_shortlink, model.post %> was flagged (<%= format_text(model.reason.chomp(".").strip, inline: true) %>).
<% end %>
</div>
<% when "PostReplacement" %>
<%= link_to_user user %> replaced <%= link_to model.post.dtext_shortlink, model.post %> with <%= external_link_to Source::URL.page_url(model.replacement_url) || model.replacement_url %>.
<% when "PostVote" %>
<%= link_to_user user %> <%= model.is_positive? ? "upvoted" : "downvoted" %> <%= link_to model.post.dtext_shortlink, model.post %>.
<% when "SavedSearch" %>
<%= link_to_user user %> saved search <%= link_to model.query, posts_path(tag: model.query) %>.
<% when "Upload" %>
<%= link_to_user user %> created <%= link_to model.dtext_shortlink, model %>.
<% when "User" %>
<%= link_to_user user %> created their account.
<% when "UserEvent" %>
<% case model.category %>
<% when "login" %>
<%= link_to_user user %> logged in.
<% when "failed_login" %>
Failed login attempt for <%= link_to_user user %>.
<% when "logout" %>
<%= link_to_user user %> logged out.
<% when "user_creation" %>
<%= link_to_user user %> created their account.
<% when "user_deletion" %>
<%= link_to_user user %> deleted their account.
<% when "password_reset" %>
<%= link_to_user user %> reset their password.
<% when "password_reset" %>
<%= link_to_user user %> changed their password.
<% when "email_change" %>
<%= link_to_user user %> changed their email address.
<% end %>
<% when "UserFeedback" %>
<% case event_type %>
<% when "create" %>
<%= link_to_user model.creator %> created a <%= link_to "#{model.category} feedback", model %> for <%= link_to_user model.user %>.
<% when "subject" %>
<%= link_to_user model.user %> received a <%= link_to "#{model.category} feedback", model %> from <%= link_to_user model.creator %>.
<% end %>
<% when "UserNameChangeRequest" %>
<%= link_to_user model.user %> <%= link_to "changed their name", model %> from <%= model.original_name %> to <%= model.desired_name %>.
<% when "UserUpgrade" %>
<% if model.complete? %>
<%= link_to_user model.purchaser %> <%= link_to "upgraded", model %> <%= link_to_user model.recipient if model.is_gift? %> to <%= model.level_string %>.
<% elsif model.refunded? %>
<%= link_to_user model.purchaser %> <%= link_to "upgraded", model %> <%= link_to_user model.recipient if model.is_gift? %> to <%= model.level_string %> (refunded).
<% end %>
<% end %>
<% end %>
<% t.column "Category" do |user_action| %>
<%= link_to user_action.model_type.delete_suffix("Version").titleize, user_actions_path(search: { model_type: user_action.model_type }) %>
<% end %>
<% t.column "Date" do |user_action| %>
<% if policy(user_action).can_see_user? %>
<%= link_to_user user_action.user %> <%= link_to "»", user_actions_path(search: { **search_params, user_id: user_action.user_id }) %>
<% end %>
<div><%= time_ago_in_words_tagged(user_action.event_at) %></div>
<% end %>
<% end %>
</div>
<%= numbered_paginator(@user_actions) %>
</div>
</div>
<%= render "secondary_links" %>

View File

@@ -261,7 +261,9 @@ Rails.application.routes.draw do
resources :upload_media_assets, only: [:show, :index], path: "assets"
end
resources :upload_media_assets, only: [:show, :index]
resources :user_actions, only: [:index, :show]
resources :users do
resources :actions, only: [:index]
resources :favorites, only: [:index, :create, :destroy]
resources :favorite_groups, controller: "favorite_groups", only: [:index], as: "favorite_groups"
resource :email, only: [:show, :edit, :update] do

View File

@@ -3,5 +3,6 @@ FactoryBot.define do
creator
reason {"xxx"}
status { :pending }
model { build(:comment) }
end
end

View File

@@ -2,5 +2,6 @@ FactoryBot.define do
factory(:user_event) do
user
user_session
category { :login }
end
end

View File

@@ -0,0 +1,72 @@
require 'test_helper'
class UserActionsControllerTest < ActionDispatch::IntegrationTest
context "The user actions controller" do
context "index action" do
setup do
@user = create(:user)
as(@user) do
create(:artist)
create(:artist_commentary)
create(:ban)
create(:bulk_update_request)
create(:comment)
create(:comment_vote)
create(:dmail)
create(:favorite_group)
create(:forum_post)
create(:forum_post_vote)
create(:forum_topic)
create(:mod_action)
create(:moderation_report)
create(:note)
create(:post)
create(:post_appeal)
create(:post_approval)
create(:post_disapproval)
create(:post_flag)
create(:post_replacement)
create(:post_vote)
create(:wiki_page)
create(:saved_search)
create(:tag)
create(:tag_alias)
create(:tag_implication)
create(:upload)
create(:user_event)
create(:user_feedback)
create(:user_name_change_request)
create(:user_upgrade)
create(:wiki_page)
end
end
should "render for the owner" do
get_auth user_events_path(limit: 1000), create(:admin_user)
assert_response :success
end
should "render for an admin" do
get_auth user_events_path(limit: 1000), create(:admin_user)
assert_response :success
end
should "render for a mod" do
get_auth user_events_path(limit: 1000), create(:moderator_user)
assert_response :success
end
should "fail for a normal user" do
get_auth user_actions_path(limit: 1000), create(:user)
assert_response 403
end
should "render when filtering on a single user" do
get_auth user_events_path(user_id: @user.id, limit: 1000), create(:owner_user)
assert_response :success
end
end
end
end