Skip to content

Commit 2875111

Browse files
committed
Updates the service to only process a single student at a time and to raise on errors, allowing it to be more adaptable. Updated the task to handle iterating over students and passing to service, and handling errors, allowing better output. More comprehensive tests also added.
1 parent 8a9012c commit 2875111

File tree

4 files changed

+282
-74
lines changed

4 files changed

+282
-74
lines changed
Lines changed: 52 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,63 @@
11
# frozen_string_literal: true
22

33
class StudentRemovalService
4-
def initialize(students:, school:, remove_from_profile: false, token: nil)
5-
@students = students
4+
class NoSchoolError < StandardError; end
5+
class NoClassesError < StandardError; end
6+
class StudentHasProjectsError < StandardError; end
7+
class NoopError < StandardError; end
8+
9+
def initialize(school:, remove_from_profile: false, token: nil, raise_on_noop: false)
610
@school = school
711
@remove_from_profile = remove_from_profile
812
@token = token
13+
@raise_on_noop = raise_on_noop
914
end
1015

11-
# Returns an array of hashes, one per student, with details of what was removed
12-
def remove_students
13-
results = []
14-
@students.each do |user_id|
15-
result = { user_id: }
16-
begin
17-
# Skip if student has projects
18-
projects = Project.where(user_id: user_id)
19-
result[:skipped] = true if projects.length.positive?
20-
21-
unless result[:skipped]
22-
ActiveRecord::Base.transaction do
23-
# Remove from classes
24-
class_assignments = ClassStudent.where(student_id: user_id)
25-
class_assignments.destroy_all
26-
27-
# Remove roles
28-
roles = Role.student.where(user_id: user_id)
29-
roles.destroy_all
30-
end
31-
32-
# Remove from profile if requested
33-
ProfileApiClient.delete_school_student(token: @token, school_id: @school.id, student_id: user_id) if @remove_from_profile && @token.present?
34-
end
35-
rescue StandardError => e
36-
result[:error] = "#{e.class}: #{e.message}"
37-
end
38-
results << result
16+
# rubocop:disable Metrics/CyclomaticComplexity
17+
def remove_student(student_id)
18+
raise NoSchoolError, 'School not found' if @school.nil?
19+
raise NoClassesError, 'School has no classes' if @school.classes.empty?
20+
raise StudentHasProjectsError, 'Student has existing projects' if Project.exists?(user_id: student_id)
21+
22+
ActiveRecord::Base.transaction do
23+
roles_destroyed = remove_roles(student_id)
24+
classes_destroyed = remove_from_classes(student_id)
25+
remove_from_profile(student_id) if should_remove_from_profile?
26+
27+
raise NoopError, 'Student has no roles or class assignments to remove' if roles_destroyed.zero? && classes_destroyed.zero? && should_raise_on_noop?
3928
end
40-
results
29+
end
30+
# rubocop:enable Metrics/CyclomaticComplexity
31+
32+
private
33+
34+
def remove_from_classes(student_id)
35+
ClassStudent.where(
36+
school_class_id: @school.classes.pluck(:id),
37+
student_id: student_id
38+
).destroy_all.length
39+
end
40+
41+
def remove_roles(student_id)
42+
Role.student.where(
43+
user_id: student_id,
44+
school_id: @school.id
45+
).destroy_all.length
46+
end
47+
48+
def remove_from_profile(student_id)
49+
ProfileApiClient.delete_school_student(
50+
token: @token,
51+
school_id: @school.id,
52+
student_id: student_id
53+
)
54+
end
55+
56+
def should_remove_from_profile?
57+
@remove_from_profile && @token.present?
58+
end
59+
60+
def should_raise_on_noop?
61+
@raise_on_noop && !should_remove_from_profile?
4162
end
4263
end

lib/tasks/remove_students.rake

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,16 @@ require 'csv'
66
namespace :remove_students do
77
desc 'Remove students listed in a CSV file'
88
task run: :environment do
9+
Rails.logger.level = Logger::WARN
10+
911
students = []
1012
school_id = ENV.fetch('SCHOOL_ID', nil)
1113
remove_from_profile = ENV.fetch('REMOVE_FROM_PROFILE', 'false') == 'true'
1214
token = ENV.fetch('TOKEN', nil)
1315

14-
school = School.find(school_id)
16+
school = School.find_by(id: school_id)
1517
if school.nil?
16-
Rails.logger.error 'Please provide a school ID with SCHOOL_ID=your_school_id'
18+
Rails.logger.error 'Please provide a valid school ID with SCHOOL_ID=your_school_id'
1719
exit 1
1820
end
1921

@@ -40,36 +42,33 @@ namespace :remove_students do
4042
puts "Students to remove: #{students.size}"
4143
puts "====================\n\n"
4244
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)"
43-
print 'Are you sure you want to continue? (yes/no): '
45+
puts 'Are you sure you want to continue? (yes/no): '
4446
confirmation = $stdin.gets.strip.downcase
4547
unless confirmation == 'yes'
4648
puts 'Aborted. No students were removed.'
4749
exit 0
4850
end
4951

5052
service = StudentRemovalService.new(
51-
students: students,
5253
school: school,
5354
remove_from_profile: remove_from_profile,
54-
token: token
55+
token: token,
56+
raise_on_noop: true
5557
)
5658

57-
results = service.remove_students.map do |res|
58-
if res[:error]
59-
"Student: #{res[:user_id]} | Error: #{res[:error]}"
60-
elsif res[:skipped]
61-
"Student: #{res[:user_id]} | Skipped: has project(s)"
62-
else
63-
"Student: #{res[:user_id]} | Removed successfully"
64-
end
59+
results = []
60+
students.each do |student_id|
61+
service.remove_student(student_id)
62+
results << "Student: #{student_id} | Removed successfully"
63+
rescue StandardError => e
64+
results << "Student: #{student_id} | Error: #{e.message}"
6565
end
6666

6767
puts "\n===================="
68-
puts "Student removal results summary\n"
69-
puts "SCHOOL: #{school.name} (#{school.id})"
70-
puts "REMOVE_FROM_PROFILE: #{remove_from_profile}"
68+
puts "Results\n"
69+
puts "Students processed: #{results.size}"
7170
puts '===================='
72-
results.each { |summary| puts summary }
71+
results.each { |result| puts result }
7372
puts "====================\n\n"
7473
end
7574
end
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# frozen_string_literal: true
2+
3+
require 'rails_helper'
4+
require 'rake'
5+
require 'tempfile'
6+
7+
RSpec.describe 'remove_students', type: :task do
8+
describe ':run' do
9+
let(:task) { Rake::Task['remove_students:run'] }
10+
let(:school) { create(:school) }
11+
let(:student_1) { create(:student, school: school) }
12+
let(:student_2) { create(:student, school: school) }
13+
let(:mock_service) { instance_double(StudentRemovalService) }
14+
15+
before do
16+
# Clear the task to avoid "already invoked" errors
17+
task.reenable
18+
19+
# Mock the confirmation input to avoid interactive prompts
20+
allow($stdin).to receive(:gets).and_return("yes\n")
21+
22+
# Mock StudentRemovalService to avoid actual student removal
23+
allow(StudentRemovalService).to receive(:new).and_return(mock_service)
24+
allow(mock_service).to receive(:remove_student)
25+
26+
# Silence console output during tests
27+
allow($stdout).to receive(:puts)
28+
allow($stdout).to receive(:print)
29+
allow(Rails.logger).to receive(:error)
30+
end
31+
32+
describe 'envvar validation' do
33+
it 'exits when SCHOOL_ID is missing' do
34+
expect { task.invoke }.to raise_error(SystemExit)
35+
end
36+
37+
it 'exits when school is not found' do
38+
ENV['SCHOOL_ID'] = 'non-existent-id'
39+
40+
expect { task.invoke }.to raise_error(SystemExit)
41+
ensure
42+
ENV.delete('SCHOOL_ID')
43+
end
44+
45+
it 'exits when neither CSV nor STUDENTS is provided' do
46+
ENV['SCHOOL_ID'] = school.id
47+
48+
expect { task.invoke }.to raise_error(SystemExit)
49+
ensure
50+
ENV.delete('SCHOOL_ID')
51+
end
52+
end
53+
54+
describe 'CSV handling' do
55+
let(:csv_file) { Tempfile.new(['students', '.csv']) }
56+
57+
before do
58+
ENV['SCHOOL_ID'] = school.id
59+
end
60+
61+
after do
62+
csv_file.close
63+
csv_file.unlink
64+
ENV.delete('SCHOOL_ID')
65+
ENV.delete('CSV')
66+
end
67+
68+
it 'exits when CSV file does not exist' do
69+
ENV['CSV'] = '/non/existent/file.csv'
70+
71+
expect { task.invoke }.to raise_error(SystemExit)
72+
end
73+
74+
it 'processes valid CSV file' do
75+
csv_file.write("user_id\n#{student_1.id}\n#{student_2.id}\n")
76+
csv_file.rewind
77+
ENV['CSV'] = csv_file.path
78+
79+
expect { task.invoke }.not_to raise_error
80+
expect(mock_service).to have_received(:remove_student).with(student_1.id)
81+
expect(mock_service).to have_received(:remove_student).with(student_2.id)
82+
end
83+
end
84+
85+
describe 'STUDENTS handling' do
86+
before do
87+
ENV['SCHOOL_ID'] = school.id
88+
end
89+
90+
after do
91+
ENV.delete('SCHOOL_ID')
92+
ENV.delete('STUDENTS')
93+
end
94+
95+
it 'processes csv student list' do
96+
ENV['STUDENTS'] = "#{student_1.id}, #{student_2.id}"
97+
98+
expect { task.invoke }.not_to raise_error
99+
expect(mock_service).to have_received(:remove_student).with(student_1.id)
100+
expect(mock_service).to have_received(:remove_student).with(student_2.id)
101+
end
102+
end
103+
104+
describe 'user confirmation' do
105+
before do
106+
ENV['SCHOOL_ID'] = school.id
107+
ENV['STUDENTS'] = student_1.id
108+
end
109+
110+
after do
111+
ENV.delete('SCHOOL_ID')
112+
ENV.delete('STUDENTS')
113+
end
114+
115+
it 'exits when user does not confirm' do
116+
allow($stdin).to receive(:gets).and_return("no\n")
117+
118+
expect { task.invoke }.to raise_error(SystemExit)
119+
expect(mock_service).not_to have_received(:remove_student)
120+
end
121+
122+
it 'proceeds when user confirms with "yes"' do
123+
allow($stdin).to receive(:gets).and_return("yes\n")
124+
125+
expect { task.invoke }.not_to raise_error
126+
expect(mock_service).to have_received(:remove_student).with(student_1.id)
127+
end
128+
end
129+
end
130+
end

0 commit comments

Comments
 (0)