Delete User Functionality (#1615)

* add full delete route and update banisher

* add user deletion spec

* enhance styling

* remove availability if banned from mentorship

* add specs

* do not show banned mentors

* delete addiional associations

* include more associations for deletion

* simplify activity deletion wtih delete_all
This commit is contained in:
Jess Lee 2019-01-23 16:57:32 -05:00 committed by Ben Halpern
parent ec2ce5d6e6
commit 0eb5402926
7 changed files with 248 additions and 120 deletions

View file

@ -32,7 +32,7 @@ class Internal::UsersController < Internal::ApplicationController
@new_mentor = user_params[:add_mentor]
ban_from_mentorship
make_matches
warn_or_ban_user
update_role
add_note
@user.update!(user_params)
if user_params[:quick_match]
@ -42,17 +42,26 @@ class Internal::UsersController < Internal::ApplicationController
end
end
def warn_or_ban_user
if user_params[:ban_user] == "1"
@user.add_role :banned
create_note("banned", user_params[:note_for_current_role])
elsif user_params[:warn_user] == "1"
@user.add_role :warned
create_note("warned", user_params[:note_for_current_role])
elsif user_params[:good_standing_user] == "1"
@user.remove_role :warned
create_note("good_standing", user_params[:note_for_current_role])
end
def update_role
ban_user if user_params[:ban_user] == "1"
warn_user if user_params[:warn_user] == "1"
return_to_good_standing if user_params[:good_standing_user] == "1"
end
def return_to_good_standing
@user.remove_role :banned if @user.banned
@user.remove_role :warned if @user.warned
create_note("good_standing", user_params[:note_for_current_role])
end
def ban_user
@user.add_role :banned
create_note("banned", user_params[:note_for_current_role])
end
def warn_user
@user.add_role :warned
create_note("warned", user_params[:note_for_current_role])
end
def add_note
@ -97,6 +106,7 @@ class Internal::UsersController < Internal::ApplicationController
mentor_relationships = MentorRelationship.where(mentee_id: @user.id)
deactivate_mentorship(mentee_relationships)
deactivate_mentorship(mentor_relationships)
@user.update(offering_mentorship: false, seeking_mentorship: false)
create_note("banned_from_mentorship", user_params[:note_for_mentorship_ban])
end
@ -109,13 +119,25 @@ class Internal::UsersController < Internal::ApplicationController
def banish
@user = User.find(params[:id])
begin
Moderator::Banisher.call(admin: current_user, offender: @user)
Moderator::Banisher.call_banish(admin: current_user, offender: @user)
rescue StandardError => e
flash[:error] = e.message
end
redirect_to "/internal/users/#{@user.id}/edit"
end
def full_delete
@user = User.find(params[:id])
username = @user.username
begin
Moderator::Banisher.call_delete_activity(admin: current_user, offender: @user)
flash[:notice] = "@" + username + " has been fully deleted. If this is a GDPR delete, remember to delete them from Mailchimp and Google Analytics."
rescue StandardError => e
flash[:error] = e.message
end
redirect_to "/internal/users"
end
private
def user_params

View file

@ -2,10 +2,14 @@ module Moderator
class Banisher
attr_reader :user, :admin
def self.call(admin:, offender:)
def self.call_banish(admin:, offender:)
new(offender: offender, admin: admin).banish
end
def self.call_delete_activity(admin:, offender:)
new(offender: offender, admin: admin).full_delete
end
def initialize(admin:, offender:)
@user = offender
@admin = admin
@ -39,29 +43,23 @@ module Moderator
end
end
def delete_reactions
user.reactions.find_each(&:delete)
end
def delete_comments
return unless user.comments.count.positive?
return unless user.comments.any?
user.comments.find_each do |comment|
comment.reactions.find_each(&:delete)
comment.reactions.delete_all
CacheBuster.new.bust_comment(comment.commentable, user.username)
comment.delete
end
end
def delete_follows
user.follows.find_each(&:delete)
end
def delete_articles
return unless user.articles.any?
user.articles.find_each do |article|
article.reactions.find_each(&:delete)
article.reactions.delete_all
article.comments.find_each do |comment|
comment.reactions.find_each(&:delete)
comment.reactions.delete_all
CacheBuster.new.bust_comment(comment.commentable, comment.user.username)
comment.delete
end
@ -71,15 +69,30 @@ module Moderator
end
end
def delete_user_activity
user.notifications.delete_all
user.reactions.delete_all
user.follows.delete_all
Follow.where(followable_id: user.id, followable_type: "User").delete_all
user.chat_channel_memberships.delete_all
user.mentions.delete_all
user.badge_achievements.delete_all
user.github_repos.delete_all
delete_comments
delete_articles
end
def full_delete
delete_user_activity
CacheBuster.new.bust("/#{user.old_username}")
user.delete
end
def banish
# return unless user.comments.where("created_at < ?", 150.days.ago).empty?
reassign_and_bust_username
remove_profile_info
add_banned_role
delete_reactions
delete_comments
delete_articles
delete_follows
delete_user_activity
user.remove_from_algolia_index
user.save!
end

View file

@ -1,23 +1,45 @@
<h1><a href="<%= @user.path %>"><%= @user.name %></a></h1>
<h2>@<%= @user.username %></h2>
<h4><%= @user.comments.size %> comments</h4>
<h4><%= @user.articles.size %> articles</h4>
<h4><%= @user.reactions.size %> reactions</h4>
<h4>Joined <%= @user.created_at.to_formatted_s(:long_ordinal) %></h4>
<% if @user.banned %>
<h1 style="color:red">User is banned</h1>
<% @user.notes.each do |note| %>
<b><%= note.reason %></b> | <%= note.content %>
<% end %>
<% else %>
<h1>
<%= @user.name%><a href="/<%= @user.username %>" target="_blank"> (@<%= @user.username %>)</a>
</h1>
<p><u><a style="color: green" href="/internal/users/<%= @user.id %>">click here view user details & take other actions</a></u></p>
<p><em>Member since <%= @user.created_at.strftime("%b %e '%y") %></em></p>
<p><strong>Activity:</strong></p>
<ul>
<li><%= @user.comments.size %> comments</li>
<li><%= @user.articles.size %> articles</li>
<li><%= @user.reactions.size %> reactions</li>
</ul>
<div class="row">
<% if @user.comments.where("created_at < ?", 100.days.ago).empty? %>
<%= form_for(@user, :url => banish_internal_user_path(@user), :html => {:method => :post, :onsubmit => "confirm('Are you sure? This is extremely destructive and will change their username to @spam_###. Do not do it lightly.')"}) do %>
<h2>Banish User</h2>
<% if @user.comments.where("created_at < ?", 100.days.ago).empty? && current_user.has_role?(:super_admin) %>
<p><strong>This is not a new user.</strong> You are only allowed to take this action because you are a <strong>super admin.</strong></p>
<p><em>This is extremely destructive. Banishing will delete all the user's existing content and change their username to @spam_###.</em></p>
<p><strong>Do not do this lightly.</strong></p>
<%= form_for(@user, :url => banish_internal_user_path(@user), :html => {:method => :post, :onsubmit => "confirm('Are you sure? This is extremely destructive. Banishing will delete all the user's existing content and change their username to @spam_###. Do not do it lightly.')"}) do %>
<button class="btn btn-danger" style="font-size:2em;">Banish User for Spam!</button>
<% end %>
<em>This is extremely destructive and will change their username to @spam_###. Do not do it lightly.</em>
<% elsif @user.comments.where("created_at < ?", 100.days.ago).empty? %>
<p><em>This is extremely destructive. Banishing will delete all the user's existing content and change their username to @spam_###.</em></p>
<p><strong>Do not do this lightly.</strong></p>
<%= form_for(@user, :url => banish_internal_user_path(@user), :html => {:method => :post, :onsubmit => "confirm('Are you sure? This is extremely destructive. Banishing will delete all the user's existing content and change their username to @spam_###. Do not do it lightly.')"}) do %>
<button class="btn btn-danger" style="font-size:2em;">Banish User for Spam</button>
<% end %>
<% else %>
<em>You cannot take admin action from this view because this is not a newish user. For safety.</em>
<p><em>This is not a new user so you'll need to contact a super admin to banish this user.</em></p>
<% end %>
</div>
<% end %>
<div class="row">
<h2>Fully Delete User</h2>
<% if current_user.has_role?(:super_admin) %>
<p>This will <strong>completely destroy the user</strong> and all of their activity from our database. This action is irreversible.</p>
<p><strong>Do not do this lightly.</strong></p>
<%= form_for(@user, :url => full_delete_internal_user_path(@user), :html => {:method => :post, :onsubmit => "confirm('Are you sure? This is extremely destructive and irreversible.')"}) do %>
<button class="btn btn-danger" style="font-size:2em;">Fully Delete User & All Activity</button>
<% end %>
<% else %>
<p>Only super admins can fully delete users.</p>
<% end %>
</div>

View file

@ -1,3 +1,8 @@
<style>
.alert {
border: 2px red solid;
}
</style>
<h3 class="top-nav">
<a href="/internal/users">All Users</a> ||
<a href="/internal/users?state=mentors">Mentors Only </a>||

View file

@ -2,8 +2,18 @@
<h1>
<%= @user.name%><a href="/<%= @user.username %>" target="_blank"> (@<%= @user.username %>)</a>
</h1>
<p><u><a style="color: red" href="/internal/users/<%= @user.id %>/edit">click here to banish or delete user</a></u></p>
<p><em>Member since <%= @user.created_at.strftime("%b %e '%y") %></em></p>
<p><strong>Email:</strong> <%= @user.email %></p>
<p><strong>Activity:</strong></p>
<ul>
<li><%= @user.comments.size %> comments</li>
<li><%= @user.articles.size %> articles</li>
<li><%= @user.reactions.size %> reactions</li>
</ul>
</div>
<div class="row">
<h3><u>Current Roles</u></h3>
<ul>
<% @user.roles.each do |role| %>
@ -11,9 +21,17 @@
<% end %>
</ul>
<%= form_for ([:internal, @user]) do |f| %>
<% if @user.banned %>
<% if @user.banned && current_user.has_role?(:super_admin) %>
<p style="background-color:red;color:white">🚨 Banned User 🚨</p>
<p>You can only unban this user through the admin panel.</p>
<%= f.label "Un-ban User (return to good standing)" %>
<%= f.check_box :good_standing_user %>
<br>
<br>
<%= f.submit "Update User Status" %>
<% elsif @user.banned %>
<p style="background-color:red;color:white">🚨 Banned User 🚨</p>
<p>Only super-admins can unban users.</p>
<% elsif @user.warned %>
<p style="background-color: orange;color:white">Warned User</p>
<%= f.label :ban_user %>
@ -107,7 +125,7 @@
<% end %>
</div>
<% if @user.seeking_mentorship && @user.mentee_description.present? %>
<% if @user.seeking_mentorship && @user.mentee_description.present? && !@user.banned_from_mentorship %>
<h2> Mentee Description </h2>
<div class="row" style="font-size:0.85em;position:sticky;top:50px;background:white;z-index:1000;border:2px solid black">
<div class="col-md-2">
@ -139,39 +157,40 @@
order("mentor_form_updated_at DESC").limit(175).
sort_by { |possible_mentor| UserSimilarity.new(@user, possible_mentor).score }.reverse.
first(40).each do |possible_mentor| %>
<div class="row" style="font-size:0.9em">
<div class="col-md-2">
<img src="<%= possible_mentor.profile_image_90 %>" style="border-radius:100px;width:50px;height:50px;" />
<br/>
<a href="/<%= possible_mentor.username %>" target="_blank">@<%= possible_mentor.username %></a>
<br/>
<a href="/internal/users/<%= possible_mentor.id %>" target="_blank">Internal(<%= possible_mentor.id %>)</a>
<% if !possible_mentor.banned_from_mentorship %>
<div class="row" style="font-size:0.9em">
<div class="col-md-2">
<img src="<%= possible_mentor.profile_image_90 %>" style="border-radius:100px;width:50px;height:50px;" />
<br/>
<a href="/<%= possible_mentor.username %>" target="_blank">@<%= possible_mentor.username %></a>
<br/>
<a href="/internal/users/<%= possible_mentor.id %>" target="_blank">Internal Profile (id #<%= possible_mentor.id %>)</a>
</div>
<div class="col-md-3" style="border-left:1px solid black">
<h4>Tag Intersection</h4>
<%= UserSimilarity.new(@user, possible_mentor).tag_intersection.join(", ") %>
</div>
<div class="col-md-3">
<h4>Mentor description</h4>
<%= possible_mentor.mentor_description %>
</div>
<div class="col-md-3">
<h4>Skills/Languages</h4>
<%= possible_mentor.mostly_work_with %>
</div>
<div class="col-md-1">
<h4>Action</h4>
<%= form_for ([:internal, @user]) do |f| %>
<%= f.hidden_field :quick_match, value: true %>
<%= f.hidden_field :add_mentor, value: possible_mentor.id %>
<%= f.submit "Match!" %>
<% end %>
</div>
</div>
<div class="col-md-3" style="border-left:1px solid black">
<h4>Tag Intersection</h4>
<%= UserSimilarity.new(@user, possible_mentor).tag_intersection.join(", ") %>
</div>
<div class="col-md-3">
<h4>Mentor description</h4>
<%= possible_mentor.mentor_description %>
</div>
<div class="col-md-3">
<h4>Skills/Languages</h4>
<%= possible_mentor.mostly_work_with %>
</div>
<div class="col-md-1">
<h4>Action</h4>
<%= form_for ([:internal, @user]) do |f| %>
<%= f.hidden_field :quick_match, value: true %>
<%= f.hidden_field :add_mentor, value: possible_mentor.id %>
<%= f.submit "Match!" %>
<% end %>
</div>
</div>
<% end %>
<% end %>
<% end %>
<div class="row">
<h2><u>Recent Emails</u></h2>
<ul>
<% @user.email_messages.order("sent_at DESC").limit(50).each do |email| %>

View file

@ -31,6 +31,7 @@ Rails.application.routes.draw do
resources :users do
member do
post "banish"
post "full_delete"
end
end
resources :events

View file

@ -1,57 +1,103 @@
require "rails_helper"
RSpec.describe "internal/users", type: :request do
context "when banishing user" do
let(:user) { create(:user) }
let(:user2) { create(:user) }
let(:user3) { create(:user) }
let(:super_admin) { create(:user, :super_admin) }
let(:article) { create(:article, user: user) }
let(:article2) { create(:article, user: user2) }
let(:user) { create(:user) }
let(:user2) { create(:user) }
let(:user3) { create(:user) }
let(:super_admin) { create(:user, :super_admin) }
let(:article) { create(:article, user: user) }
let(:article2) { create(:article, user: user2) }
before do
sign_in super_admin
user
user2
Delayed::Worker.delay_jobs = true
dependents_for_offending_user_article
offender_activity_on_other_content
end
after do
Delayed::Worker.delay_jobs = false
end
def dependents_for_offending_user_article
# create user2 comment on offending user article
comment = create(:comment, commentable_type: "Article", commentable: article, user: user2)
# create user3 reaction to user2 comment
create(:reaction, reactable: comment, reactable_type: "Comment", user: user3)
# create user3 comment response to user2 comment
comment2 = create(:comment, commentable_type: "Article", commentable: article, user: user3, ancestry: comment.id)
# create user2 reaction to user3 comment response
create(:reaction, reactable: comment2, reactable_type: "Comment", user: user2)
# create user3 reaction to offending article
create(:reaction, reactable: article, reactable_type: "Article", user: user3, category: "like")
Delayed::Worker.new(quiet: true).work_off
end
def offender_activity_on_other_content
# offender reacts to user2 article
create(:reaction, reactable: article2, reactable_type: "Article", user: user)
# offender comments on user2 article
comment = create(:comment, commentable_type: "Article", commentable: article2, user: user)
# user3 reacts to offender comment
create(:reaction, reactable: comment, reactable_type: "Comment", user: user3)
Delayed::Worker.new(quiet: true).work_off
end
context "when deleting user" do
def create_mention
comment = create(
:comment,
body_markdown: "Hello @#{user.username}, you are cool.",
user_id: user2.id,
commentable_id: article2.id,
)
Mention.create_all_without_delay(comment)
end
def create_mutual_follows
user.follow(user3)
follow = user3.follow(user)
Notification.send_new_follower_notification_without_delay(follow)
end
before do
sign_in super_admin
user
user2
Delayed::Worker.delay_jobs = true
dependents_for_offending_user_article
offender_activity_on_other_content
create_mutual_follows
create_mention
create(:badge_achievement, rewarder_id: 1, rewarding_context_message: "yay", user_id: user.id)
Delayed::Worker.new(quiet: true).work_off
end
after do
Delayed::Worker.delay_jobs = false
it "raises a 'record not found' error after deletion" do
post "/internal/users/#{user.id}/full_delete"
expect { User.find(user.id) }.to raise_exception(ActiveRecord::RecordNotFound)
end
end
context "when banning from mentorship" do
before do
user.update(offering_mentorship: true, mentor_description: "I want to be a mentor")
end
it "adds banned from mentorship role" do
put "/internal/users/#{user.id}", params: { user: { ban_from_mentorship: "1", note_for_mentorship_ban: "banned" } }
expect(user.roles.first.name).to eq("banned_from_mentorship")
end
it "returns user to good standing if unbanned" do
put "/internal/users/#{user.id}", params: { user: { good_standing_user: "1" } }
expect(user.roles.count).to eq(0)
end
end
context "when banishing user" do
def banish_user
post "/internal/users/#{user.id}/banish"
Delayed::Worker.new(quiet: true).work_off
user.reload
end
def dependents_for_offending_user_article
# create user2 comment on offending user article
comment = create(:comment, commentable_type: "Article", commentable: article, user: user2)
# create user3 reaction to user2 comment
create(:reaction, reactable: comment, reactable_type: "Comment", user: user3)
# create user3 comment response to user2 comment
comment2 = create(:comment, commentable_type: "Article", commentable: article, user: user3, ancestry: comment.id)
# create user2 reaction to user3 comment response
create(:reaction, reactable: comment2, reactable_type: "Comment", user: user2)
# create user3 reaction to offending article
create(:reaction, reactable: article, reactable_type: "Article", user: user3, category: "like")
Delayed::Worker.new(quiet: true).work_off
end
def offender_activity_on_other_content
# offender reacts to user2 article
create(:reaction, reactable: article2, reactable_type: "Article", user: user)
# offender comments on user2 article
comment = create(:comment, commentable_type: "Article", commentable: article2, user: user)
# user3 reacts to offender comment
create(:reaction, reactable: comment, reactable_type: "Comment", user: user3)
Delayed::Worker.new(quiet: true).work_off
end
it "reassigns username and removes profile info" do
user.currently_hacking_on = "currently hackin on !!!!!!!!!!!!"
user.save