Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/commands/mentor/discussion/retrieve.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ def initialize(mentor,

def call
setup!
filter_shadow_banned!
filter_status!
filter_track!
filter_student!
Expand All @@ -57,6 +58,10 @@ def setup!
where(mentor:)
end

def filter_shadow_banned!
@discussions = @discussions.joins(:solution).where.not(solutions: { user_id: User.shadow_banned.select(:id) })
end

def filter_status!
case status
when :awaiting_mentor
Expand Down
6 changes: 6 additions & 0 deletions app/commands/mentor/request/retrieve.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ def setup!
@requests = Mentor::Request.
pending.
unlocked_for(mentor)

# Shadow-banned mentors see empty queues
@requests = @requests.none if mentor&.shadow_banned?
end

def filter!
Expand All @@ -60,6 +63,9 @@ def filter!
select(:student_id)
)

# Don't show shadow-banned students' requests
@requests = @requests.where.not(student_id: User.shadow_banned.select(:id))

if exercise_slug.present?
filter_exercises!
else
Expand Down
6 changes: 6 additions & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ def ensure_staff!
redirect_to maintaining_root_path
end

def ensure_moderator!
return if current_user&.moderator?

redirect_to root_path
end

def ensure_iHiD! # rubocop:disable Naming/MethodName
return true if Rails.env.development?
return true if current_user&.id == User::IHID_USER_ID
Expand Down
5 changes: 5 additions & 0 deletions app/controllers/moderation/base_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class Moderation::BaseController < ApplicationController
before_action :ensure_moderator!

layout "admin"
end
28 changes: 28 additions & 0 deletions app/controllers/moderation/shadow_banned_users_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
class Moderation::ShadowBannedUsersController < Moderation::BaseController
def index
@users = User.includes(:shadow_banned_by).where.not(shadow_banned_at: nil).order(shadow_banned_at: :desc)
end

def create
handle = params[:handle]&.strip
user = User.find_by(handle: handle)

if user.nil?
flash[:alert] = "Could not find user with handle '#{handle}'"
elsif user.shadow_banned?
flash[:alert] = "#{user.handle} is already shadow banned"
else
user.update!(shadow_banned_at: Time.current, shadow_banned_by_id: current_user.id)
flash[:notice] = "#{user.handle} has been shadow banned from mentoring"
end

redirect_to moderation_shadow_banned_users_path
end

def destroy
user = User.find_by!(handle: params[:id])
user.update!(shadow_banned_at: nil, shadow_banned_by_id: nil)
flash[:notice] = "Shadow ban removed for #{user.handle}"
redirect_to moderation_shadow_banned_users_path
end
end
4 changes: 4 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ class User < ApplicationRecord

scope :with_data, -> { joins(:data) }
scope :insiders, -> { with_data.where(user_data: { insiders_status: %i[active active_lifetime] }) }
scope :shadow_banned, -> { where.not(shadow_banned_at: nil) }

# TODO: Validate presence of name
validates :handle, uniqueness: { case_sensitive: false }, handle_format: true, length: { maximum: 190 }
Expand Down Expand Up @@ -344,9 +345,12 @@ def bought_course?
def profile? = profile.present?
def may_create_profile? = reputation >= User::Profile::MIN_REPUTATION

belongs_to :shadow_banned_by, class_name: "User", optional: true

def confirmed? = super && !disabled? && !blocked?
def disabled? = !!disabled_at
def blocked? = User::BlockDomain.blocked?(user: self)
def shadow_banned? = !!shadow_banned_at

def github_auth? = uid.present?
def captcha_required? = !github_auth? && Time.current - created_at < 2.days
Expand Down
1 change: 1 addition & 0 deletions app/models/user/roles.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ def admin? = roles.include?(:admin)
def staff? = roles.include?(:staff) || admin?
def maintainer? = roles.include?(:maintainer) || staff?
def supermentor? = roles.include?(:supermentor) || staff?
def moderator? = roles.include?(:moderator) || staff?
def mentor? = became_mentor_at.present? || staff?
def roles = super.to_a.map(&:to_sym).to_set
end
31 changes: 31 additions & 0 deletions app/views/moderation/shadow_banned_users/index.html.haml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
.c-admin-table
.lg-container
%h1.text-h1.mb-8 Shadow Banned Users

- if flash[:notice]
.mb-12.text-p-base.font-bold.text-darkSuccessGreen= flash[:notice]
- if flash[:alert]
.mb-12.text-p-base.font-bold.text-red= flash[:alert]

= form_with(url: moderation_shadow_banned_users_path, method: :post, class: "flex items-end gap-8 mb-16") do |form|
.flex.flex-col
= form.label :handle, "User handle", class: 'text-h6 mb-4'
= form.text_field :handle, required: true
%div
= form.submit "Shadow Ban", class: 'btn btn-primary btn-base'

%table.mb-8{ style: "border-collapse: collapse; width: 100%; background: white;" }
%tr
%th{ style: "border: 1px solid #ddd; padding: 8px 12px; text-align: left;" } Handle
%th{ style: "border: 1px solid #ddd; padding: 8px 12px; text-align: left;" } Email
%th{ style: "border: 1px solid #ddd; padding: 8px 12px; text-align: left;" } Banned at
%th{ style: "border: 1px solid #ddd; padding: 8px 12px; text-align: left;" } Banned by
%th{ style: "border: 1px solid #ddd; padding: 8px 12px; text-align: left;" } Actions
- @users.each do |user|
%tr
%td{ style: "border: 1px solid #ddd; padding: 8px 12px;" }= user.handle
%td{ style: "border: 1px solid #ddd; padding: 8px 12px;" }= user.email
%td{ style: "border: 1px solid #ddd; padding: 8px 12px;" }= user.shadow_banned_at.strftime("%Y-%m-%d %H:%M")
%td{ style: "border: 1px solid #ddd; padding: 8px 12px;" }= user.shadow_banned_by&.handle || "Unknown"
%td{ style: "border: 1px solid #ddd; padding: 8px 12px;" }
= button_to "Remove", moderation_shadow_banned_user_path(user), method: :delete, class: 'btn btn-primary btn-base'
8 changes: 8 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@
resource :workflow_run_updates, only: [:create]
end

# ########## #
# Moderation #
# ########## #
namespace :moderation do
root to: redirect('/moderation/shadow_banned_users')
resources :shadow_banned_users, only: %i[index create destroy]
end

# ##### #
# Admin #
# ##### #
Expand Down
9 changes: 9 additions & 0 deletions db/migrate/20260318000000_add_shadow_banned_to_users.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
class AddShadowBannedToUsers < ActiveRecord::Migration[7.0]
def change
return if Rails.env.production?

add_column :users, :shadow_banned_at, :datetime, null: true
add_column :users, :shadow_banned_by_id, :bigint, null: true
add_foreign_key :users, :users, column: :shadow_banned_by_id
end
end
4 changes: 3 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[7.1].define(version: 2026_02_09_141203) do
ActiveRecord::Schema[7.1].define(version: 2026_03_18_000000) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
t.string "name", null: false
t.string "record_type", null: false
Expand Down Expand Up @@ -1834,6 +1834,8 @@
t.datetime "disabled_at"
t.integer "flair", limit: 1
t.integer "version", limit: 2, default: 0, null: false
t.datetime "shadow_banned_at"
t.bigint "shadow_banned_by_id"
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
t.index ["created_at"], name: "index_users_on_created_at"
t.index ["email"], name: "index_users_on_email", unique: true
Expand Down
12 changes: 12 additions & 0 deletions test/commands/mentor/discussion/retrieve_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -187,4 +187,16 @@ class Mentor::Discussion::RetrieveTest < ActiveSupport::TestCase
Mentor::Discussion::Retrieve.(user, status)
end
end

test "does not retrieve shadow-banned students' discussions" do
mentor = create :user

normal_discussion = create :mentor_discussion, :awaiting_mentor, mentor: mentor

shadow_banned_discussion = create :mentor_discussion, :awaiting_mentor, mentor: mentor
shadow_banned_student = shadow_banned_discussion.solution.user
shadow_banned_student.update!(shadow_banned_at: Time.current)

assert_equal [normal_discussion], Mentor::Discussion::Retrieve.(mentor, :awaiting_mentor, page: 1)
end
end
29 changes: 29 additions & 0 deletions test/commands/mentor/request/retrieve_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -173,4 +173,33 @@ class Mentor::Request::RetrieveTest < ActiveSupport::TestCase
assert requests.is_a?(ActiveRecord::Relation)
refute_respond_to requests, :current_page
end

test "does not retrieve shadow-banned students' requests" do
mentored_track = create :track
mentor = create :user
create :user_track_mentorship, user: mentor, track: mentored_track

normal_student = create :user
banned_student = create :user, shadow_banned_at: Time.current

normal_solution = create :concept_solution, track: mentored_track, user: normal_student
banned_solution = create :concept_solution, track: mentored_track, user: banned_student

normal_request = create :mentor_request, solution: normal_solution
create :mentor_request, solution: banned_solution

assert_equal [normal_request], Mentor::Request::Retrieve.(mentor:)
end

test "shadow-banned mentor sees empty queue" do
mentored_track = create :track
mentor = create :user, shadow_banned_at: Time.current
create :user_track_mentorship, user: mentor, track: mentored_track

student = create :user
solution = create :concept_solution, track: mentored_track, user: student
create :mentor_request, solution: solution

assert_empty Mentor::Request::Retrieve.(mentor:)
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
require "test_helper"

class Moderation::ShadowBannedUsersControllerTest < ActionDispatch::IntegrationTest
test "index redirects if not signed in" do
get moderation_shadow_banned_users_url

assert_response :redirect
end

test "index redirects if not moderator" do
sign_in!

get moderation_shadow_banned_users_url

assert_response :redirect
end

test "index succeeds for moderator" do
user = create(:user, :staff)

sign_in!(user)

get moderation_shadow_banned_users_url

assert_response :success
end

test "create shadow bans a user" do
moderator = create(:user, :staff)
student = create :user

sign_in!(moderator)

post moderation_shadow_banned_users_url, params: { handle: student.handle }

student.reload
assert student.shadow_banned?
assert_equal moderator.id, student.shadow_banned_by_id
assert_redirected_to moderation_shadow_banned_users_url
end

test "create handles unknown handle" do
moderator = create(:user, :staff)
sign_in!(moderator)

post moderation_shadow_banned_users_url, params: { handle: "nonexistent" }

assert_redirected_to moderation_shadow_banned_users_url
end

test "destroy removes shadow ban" do
moderator = create(:user, :staff)
student = create :user, shadow_banned_at: Time.current, shadow_banned_by_id: moderator.id

sign_in!(moderator)

delete moderation_shadow_banned_user_url(student)

student.reload
refute student.shadow_banned?
assert_nil student.shadow_banned_by_id
assert_redirected_to moderation_shadow_banned_users_url
end
end
Loading