Skip to content
Open
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
83 changes: 52 additions & 31 deletions app/services/student_removal_service.rb
Original file line number Diff line number Diff line change
@@ -1,42 +1,63 @@
# frozen_string_literal: true

class StudentRemovalService
def initialize(students:, school:, remove_from_profile: false, token: nil)
@students = students
class NoSchoolError < StandardError; end
class NoClassesError < StandardError; end
class StudentHasProjectsError < StandardError; end
class NoopError < StandardError; end
Comment on lines +4 to +7
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you set up a BaseError class, then in the rake task you can just rescue anything

  # Set up a BaseError class so we can rescue all errors
  BaseError = Class.new(StandardError)
  ## Specific errors
  NoSchoolError = Class.new(BaseError)
  NoClassesError = Class.new(BaseError)
  StudentHasProjectsError = Class.new(BaseError)
  NoopError = Class.new(BaseError)

then in the rake task

begin
  ...
rescue StudentRemovalService::BaseError => e
  # whatever it is here
end


def initialize(school:, remove_from_profile: false, token: nil, raise_on_noop: false)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess a fundamental question is why is this asking for a school when the service is a "student removal service"? I'd suggest maybe renaming it to SchoolStudentsRemovalService or let it handle student IDs directly, and pass them in one at a time.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's because you can only remove them from profile from a school context (i.e. by passing a school), and that is also how support requests will come in - a student is unique to the school, not globally

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but yes, that's good call about altering the name - that'll help make it clearer

@school = school
@remove_from_profile = remove_from_profile
@token = token
@raise_on_noop = raise_on_noop
end

# Returns an array of hashes, one per student, with details of what was removed
def remove_students
results = []
@students.each do |user_id|
result = { user_id: }
begin
# Skip if student has projects
projects = Project.where(user_id: user_id)
result[:skipped] = true if projects.length.positive?

unless result[:skipped]
ActiveRecord::Base.transaction do
# Remove from classes
class_assignments = ClassStudent.where(student_id: user_id)
class_assignments.destroy_all

# Remove roles
roles = Role.student.where(user_id: user_id)
roles.destroy_all
end

# Remove from profile if requested
ProfileApiClient.delete_school_student(token: @token, school_id: @school.id, student_id: user_id) if @remove_from_profile && @token.present?
end
rescue StandardError => e
result[:error] = "#{e.class}: #{e.message}"
end
results << result
# rubocop:disable Metrics/CyclomaticComplexity
def remove_student(student_id)
raise NoSchoolError, 'School not found' if @school.nil?
Copy link
Contributor

@patch0 patch0 Sep 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be better to go in the initializer. Also the error message isn't correct "School not found" isn't what's going on here. Maybe the caller (where the school lookup is happening) should be sorting this.

raise NoClassesError, 'School has no classes' if @school.classes.empty?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this something that is important to check? I don't think the caller cares if there are no classes. They might want the student removing from the school roles, or from profile.

raise StudentHasProjectsError, 'Student has existing projects' if Project.exists?(user_id: student_id)

ActiveRecord::Base.transaction do
roles_destroyed = remove_roles(student_id)
classes_destroyed = remove_from_classes(student_id)
remove_from_profile(student_id) if should_remove_from_profile?

raise NoopError, 'Student has no roles or class assignments to remove' if roles_destroyed.zero? && classes_destroyed.zero? && should_raise_on_noop?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does anyone care if it is a noop? Seems a bit extra, and would simplify code if it didn't need to check this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added this because I was struggling without it, when running the task I had no idea if anything was actually being done when should_remove_from_profile was false, so I think it helps to get a summary of what has happened even if it's nothing

end
results
end
# rubocop:enable Metrics/CyclomaticComplexity

private

def remove_from_classes(student_id)
ClassStudent.where(
school_class_id: @school.classes.pluck(:id),
student_id: student_id
).destroy_all.length
end

def remove_roles(student_id)
Role.student.where(
user_id: student_id,
school_id: @school.id
).destroy_all.length
end

def remove_from_profile(student_id)
ProfileApiClient.delete_school_student(
token: @token,
school_id: @school.id,
student_id: student_id
)
end

def should_remove_from_profile?
@remove_from_profile && @token.present?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd probably just move this into the remove_from_profile method, and return early if no token is given, or @remove_from_profile is set.

end

def should_raise_on_noop?
@raise_on_noop && !should_remove_from_profile?
end
end
31 changes: 15 additions & 16 deletions lib/tasks/remove_students.rake
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@ require 'csv'
namespace :remove_students do
desc 'Remove students listed in a CSV file'
task run: :environment do
Rails.logger.level = Logger::WARN

students = []
school_id = ENV.fetch('SCHOOL_ID', nil)
remove_from_profile = ENV.fetch('REMOVE_FROM_PROFILE', 'false') == 'true'
token = ENV.fetch('TOKEN', nil)

school = School.find(school_id)
if school.nil?
Rails.logger.error 'Please provide a school ID with SCHOOL_ID=your_school_id'
Rails.logger.error 'Please provide a valid school ID with SCHOOL_ID=your_school_id'
exit 1
end

Expand All @@ -40,36 +42,33 @@ namespace :remove_students do
puts "Students to remove: #{students.size}"
puts "====================\n\n"
puts "Please confirm deletion of #{students.size} user(s), and that recent Postgres backups have been captured for all services affected (https://devcenter.heroku.com/articles/heroku-postgres-backups#manual-backups)"
print 'Are you sure you want to continue? (yes/no): '
puts 'Are you sure you want to continue? (yes/no): '
confirmation = $stdin.gets.strip.downcase
unless confirmation == 'yes'
puts 'Aborted. No students were removed.'
exit 0
end

service = StudentRemovalService.new(
students: students,
school: school,
remove_from_profile: remove_from_profile,
token: token
token: token,
raise_on_noop: true
)

results = service.remove_students.map do |res|
if res[:error]
"Student: #{res[:user_id]} | Error: #{res[:error]}"
elsif res[:skipped]
"Student: #{res[:user_id]} | Skipped: has project(s)"
else
"Student: #{res[:user_id]} | Removed successfully"
end
results = []
students.each do |student_id|
service.remove_student(student_id)
results << "Student: #{student_id} | Removed successfully"
rescue StandardError => e
results << "Student: #{student_id} | Error: #{e.message}"
Comment on lines +63 to +64
Copy link

Copilot AI Sep 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Catching StandardError is too broad and could mask unexpected errors. Consider catching specific exceptions like StudentRemovalService::StudentHasProjectsError, StudentRemovalService::NoopError, etc., and re-raising unexpected errors to avoid silently handling system errors.

Suggested change
rescue StandardError => e
results << "Student: #{student_id} | Error: #{e.message}"
rescue StudentRemovalService::StudentHasProjectsError, StudentRemovalService::NoopError => e
results << "Student: #{student_id} | Error: #{e.message}"
rescue => e
raise

Copilot uses AI. Check for mistakes.

end

puts "\n===================="
puts "Student removal results summary\n"
puts "SCHOOL: #{school.name} (#{school.id})"
puts "REMOVE_FROM_PROFILE: #{remove_from_profile}"
puts "Results\n"
puts "Students processed: #{results.size}"
puts '===================='
results.each { |summary| puts summary }
results.each { |result| puts result }
puts "====================\n\n"
end
end
130 changes: 130 additions & 0 deletions spec/lib/tasks/remove_students_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# frozen_string_literal: true

require 'rails_helper'
require 'rake'
require 'tempfile'

RSpec.describe 'remove_students', type: :task do
describe ':run' do
let(:task) { Rake::Task['remove_students:run'] }
let(:school) { create(:school) }
let(:student_1) { create(:student, school: school) }
let(:student_2) { create(:student, school: school) }
let(:mock_service) { instance_double(StudentRemovalService) }

before do
# Clear the task to avoid "already invoked" errors
task.reenable

# Mock the confirmation input to avoid interactive prompts
allow($stdin).to receive(:gets).and_return("yes\n")

# Mock StudentRemovalService to avoid actual student removal
allow(StudentRemovalService).to receive(:new).and_return(mock_service)
allow(mock_service).to receive(:remove_student)

# Silence console output during tests
allow($stdout).to receive(:puts)
allow($stdout).to receive(:print)
allow(Rails.logger).to receive(:error)
end

describe 'envvar validation' do
it 'exits when SCHOOL_ID is missing' do
expect { task.invoke }.to raise_error(SystemExit)
end

it 'exits when school is not found' do
ENV['SCHOOL_ID'] = 'non-existent-id'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use ClimateControl to manage ENV in testing.


expect { task.invoke }.to raise_error(SystemExit)
ensure
ENV.delete('SCHOOL_ID')
end

it 'exits when neither CSV nor STUDENTS is provided' do
ENV['SCHOOL_ID'] = school.id

expect { task.invoke }.to raise_error(SystemExit)
ensure
ENV.delete('SCHOOL_ID')
end
end

describe 'CSV handling' do
let(:csv_file) { Tempfile.new(['students', '.csv']) }

before do
ENV['SCHOOL_ID'] = school.id
end

after do
csv_file.close
csv_file.unlink
ENV.delete('SCHOOL_ID')
ENV.delete('CSV')
end

it 'exits when CSV file does not exist' do
ENV['CSV'] = '/non/existent/file.csv'

expect { task.invoke }.to raise_error(SystemExit)
end

it 'processes valid CSV file' do
csv_file.write("user_id\n#{student_1.id}\n#{student_2.id}\n")
csv_file.rewind
ENV['CSV'] = csv_file.path

expect { task.invoke }.not_to raise_error
expect(mock_service).to have_received(:remove_student).with(student_1.id)
expect(mock_service).to have_received(:remove_student).with(student_2.id)
end
end

describe 'STUDENTS handling' do
before do
ENV['SCHOOL_ID'] = school.id
end

after do
ENV.delete('SCHOOL_ID')
ENV.delete('STUDENTS')
end

it 'processes csv student list' do
ENV['STUDENTS'] = "#{student_1.id}, #{student_2.id}"

expect { task.invoke }.not_to raise_error
expect(mock_service).to have_received(:remove_student).with(student_1.id)
expect(mock_service).to have_received(:remove_student).with(student_2.id)
end
end

describe 'user confirmation' do
before do
ENV['SCHOOL_ID'] = school.id
ENV['STUDENTS'] = student_1.id
end

after do
ENV.delete('SCHOOL_ID')
ENV.delete('STUDENTS')
end

it 'exits when user does not confirm' do
allow($stdin).to receive(:gets).and_return("no\n")

expect { task.invoke }.to raise_error(SystemExit)
expect(mock_service).not_to have_received(:remove_student)
end

it 'proceeds when user confirms with "yes"' do
allow($stdin).to receive(:gets).and_return("yes\n")

expect { task.invoke }.not_to raise_error
expect(mock_service).to have_received(:remove_student).with(student_1.id)
end
end
end
end
Loading