Skip to content

Commit 29c877a

Browse files
committed
Add endpoint for mass user deletion.
This code: 1. Modifies StudentRemovalService to mass-delete users and their projects instead of skipping users who have projects. 2. Adds an endpoint for school owners to mass-delete students.
1 parent 2a6ab27 commit 29c877a

File tree

8 files changed

+490
-47
lines changed

8 files changed

+490
-47
lines changed

app/controllers/api/school_students_controller.rb

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,17 +110,63 @@ def update
110110
end
111111

112112
def destroy
113-
result = SchoolStudent::Delete.call(school: @school, student_id: params[:id], token: current_user.token)
113+
remove_students([params[:id]])
114+
end
114115

115-
if result.success?
116-
head :no_content
117-
else
118-
render json: { error: result[:error] }, status: :unprocessable_entity
116+
def destroy_batch
117+
# DELETE /api/schools/:school_id/students/batch
118+
# Params: { student_ids: ["uuid1", "uuid2", ...] }
119+
#
120+
# Returns 200 OK with one of:
121+
# - Success: { results: [{ user_id: "..." }, ...] }
122+
# - Partial failure: { results: [...], error: "N student(s) failed to be removed" }
123+
#
124+
# Each result may contain:
125+
# - { user_id:, error: } - deletion failed
126+
# - { user_id:, skipped:, reason: } - student skipped (e.g., not in this school)
127+
# - { user_id: } - deletion succeeded
128+
129+
student_ids = student_ids_params
130+
131+
if student_ids.blank?
132+
render json: {
133+
error: 'No student IDs provided',
134+
error_type: :unprocessable_entity
135+
},
136+
status: :unprocessable_entity
137+
return
119138
end
139+
140+
# Remove duplicates to avoid redundant processing
141+
unique_student_ids = student_ids.uniq
142+
remove_students(unique_student_ids)
120143
end
121144

122145
private
123146

147+
def remove_students(student_ids)
148+
# Invoke StudentRemovalService
149+
service = StudentRemovalService.new(
150+
students: student_ids,
151+
school: @school,
152+
remove_from_profile: true,
153+
token: current_user.token
154+
)
155+
156+
results = service.remove_students
157+
158+
# Check if any errors occurred
159+
errors = results.select { |r| r[:error] }
160+
if errors.any?
161+
render json: {
162+
results: results,
163+
error: "#{errors.size} student(s) failed to be removed"
164+
}, status: :ok
165+
else
166+
render json: { results: results }, status: :ok
167+
end
168+
end
169+
124170
def school_student_params
125171
params.require(:school_student).permit(:username, :password, :name)
126172
end
@@ -135,6 +181,10 @@ def school_students_params
135181
end
136182
end
137183

184+
def student_ids_params
185+
params.fetch(:student_ids, [])
186+
end
187+
138188
def create_safeguarding_flags
139189
create_teacher_safeguarding_flag
140190
create_owner_safeguarding_flag

app/models/ability.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ def define_school_owner_abilities(school:)
6666
can(%i[read create create_batch destroy], ClassStudent, school_class: { school: { id: school.id } })
6767
can(%i[read create destroy], :school_owner)
6868
can(%i[read create destroy], :school_teacher)
69-
can(%i[read create create_batch update destroy], :school_student)
69+
can(%i[read create create_batch update destroy destroy_batch], :school_student)
7070
can(%i[create create_copy], Lesson, school_id: school.id)
7171
can(%i[read update destroy], Lesson, school_id: school.id, visibility: %w[teachers students public])
7272
can(%i[read destroy], Feedback, school_project: { school_id: school.id })

app/services/student_removal_service.rb

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,25 +11,31 @@ def initialize(students:, school:, remove_from_profile: false, token: nil)
1111
# Returns an array of hashes, one per student, with details of what was removed
1212
def remove_students
1313
results = []
14+
1415
@students.each do |user_id|
16+
# Ensure that the student has a role in this school and skip if not.
17+
student_roles = Role.student.where(user_id:, school_id: @school.id)
18+
if student_roles.empty?
19+
results << { user_id:, skipped: true, reason: 'no_role_in_school' }
20+
next
21+
end
22+
1523
result = { user_id: }
1624
begin
17-
# Skip if student has projects
18-
projects = Project.where(user_id: user_id)
19-
result[:skipped] = true if projects.length.positive?
25+
ActiveRecord::Base.transaction do
26+
# Delete all projects for this user
27+
projects = Project.where(user_id: user_id)
28+
projects.destroy_all
2029

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
30+
# Remove from classes
31+
class_assignments = ClassStudent.joins(:school_class).where(student_id: user_id, school_class: { school_id: @school.id })
32+
class_assignments.destroy_all
2633

27-
# Remove roles
28-
roles = Role.student.where(user_id: user_id)
29-
roles.destroy_all
30-
end
34+
# Remove roles
35+
student_roles.destroy_all
3136

32-
# Remove from profile if requested
37+
# Remove from profile if requested - inside transaction so it can be rolled back
38+
# If this call fails, the entire transaction will be rolled back
3339
ProfileApiClient.delete_school_student(token: @token, school_id: @school.id, student_id: user_id) if @remove_from_profile && @token.present?
3440
end
3541
rescue StandardError => e

config/routes.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
resources :teachers, only: %i[index create], controller: 'school_teachers'
7070
resources :students, only: %i[index create update destroy], controller: 'school_students' do
7171
post :batch, on: :collection, to: 'school_students#create_batch'
72+
delete :batch, on: :collection, to: 'school_students#destroy_batch'
7273
end
7374
end
7475

lib/tasks/remove_students.rake

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ namespace :remove_students do
3939
puts "REMOVE_FROM_PROFILE: #{remove_from_profile}"
4040
puts "Students to remove: #{students.size}"
4141
puts "====================\n\n"
42+
puts "WARNING: All student projects will be deleted permanently and this operation is not reversible.\n"
4243
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)"
4344
print 'Are you sure you want to continue? (yes/no): '
4445
confirmation = $stdin.gets.strip.downcase
@@ -58,7 +59,7 @@ namespace :remove_students do
5859
if res[:error]
5960
"Student: #{res[:user_id]} | Error: #{res[:error]}"
6061
elsif res[:skipped]
61-
"Student: #{res[:user_id]} | Skipped: has project(s)"
62+
"Student: #{res[:user_id]} | Skipped: #{res[:reason]}"
6263
else
6364
"Student: #{res[:user_id]} | Removed successfully"
6465
end
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
# frozen_string_literal: true
2+
3+
require 'rails_helper'
4+
5+
RSpec.describe 'Batch deleting school students', type: :request do
6+
let(:school) { create(:school) }
7+
let(:owner) { create(:owner, school: school) }
8+
let(:teacher) { create(:teacher, school: school) }
9+
let(:school_class) { create(:school_class, school: school, teacher_ids: [teacher.id]) }
10+
let(:student_1) { create(:student, school: school) }
11+
let(:student_2) { create(:student, school: school) }
12+
let(:headers) { { Authorization: UserProfileMock::TOKEN } }
13+
14+
before do
15+
authenticated_in_hydra_as(owner)
16+
stub_profile_api_create_safeguarding_flag
17+
create(:class_student, student_id: student_1.id, school_class: school_class)
18+
create(:class_student, student_id: student_2.id, school_class: school_class)
19+
end
20+
21+
describe 'DELETE /api/schools/:school_id/students/batch' do
22+
before do
23+
stub_profile_api_delete_school_student
24+
end
25+
26+
it 'calls ProfileApiClient to delete each student from the profile service' do
27+
delete "/api/schools/#{school.id}/students/batch",
28+
params: { student_ids: [student_1.id, student_2.id] },
29+
headers: headers
30+
31+
expect(response).to have_http_status(:ok)
32+
expect(ProfileApiClient).to have_received(:delete_school_student).with(token: UserProfileMock::TOKEN, school_id: school.id, student_id: student_1.id)
33+
expect(ProfileApiClient).to have_received(:delete_school_student).with(token: UserProfileMock::TOKEN, school_id: school.id, student_id: student_2.id)
34+
end
35+
36+
it 'deletes all students and their projects' do
37+
project_1 = create(:project, user_id: student_1.id)
38+
project_2 = create(:project, user_id: student_2.id)
39+
40+
expect(ClassStudent.where(student_id: student_1.id)).to exist
41+
expect(ClassStudent.where(student_id: student_2.id)).to exist
42+
expect(Project.where(id: project_1.id)).to exist
43+
expect(Project.where(id: project_2.id)).to exist
44+
45+
delete "/api/schools/#{school.id}/students/batch",
46+
params: { student_ids: [student_1.id, student_2.id] },
47+
headers: headers
48+
49+
expect(response).to have_http_status(:ok)
50+
51+
json = JSON.parse(response.body)
52+
expect(json['results'].size).to eq(2)
53+
expect(json['results'].all? { |r| r['error'].nil? }).to be true
54+
55+
# Verify students removed from classes
56+
expect(ClassStudent.where(student_id: student_1.id)).not_to exist
57+
expect(ClassStudent.where(student_id: student_2.id)).not_to exist
58+
59+
# Verify projects deleted
60+
expect(Project.where(id: project_1.id)).not_to exist
61+
expect(Project.where(id: project_2.id)).not_to exist
62+
end
63+
64+
it 'responds 403 Forbidden when the user is a school-teacher' do
65+
authenticated_in_hydra_as(teacher)
66+
67+
delete "/api/schools/#{school.id}/students/batch",
68+
params: { student_ids: [student_1.id, student_2.id] },
69+
headers: headers
70+
71+
expect(response).to have_http_status(:forbidden)
72+
end
73+
74+
it 'returns error when no student IDs provided' do
75+
delete "/api/schools/#{school.id}/students/batch",
76+
headers: headers
77+
78+
expect(response).to have_http_status(:unprocessable_entity)
79+
json = JSON.parse(response.body)
80+
expect(json['error']).to eq('No student IDs provided')
81+
end
82+
83+
context 'when validating input parameters' do
84+
it 'removes duplicate student IDs before processing' do
85+
duplicate_ids = [student_1.id, student_1.id, student_2.id, student_2.id, student_1.id]
86+
87+
delete "/api/schools/#{school.id}/students/batch",
88+
params: { student_ids: duplicate_ids },
89+
headers: headers
90+
91+
expect(response).to have_http_status(:ok)
92+
json = JSON.parse(response.body)
93+
# Should only process 2 unique students
94+
expect(json['results'].size).to eq(2)
95+
expect(json['results'].pluck('user_id')).to contain_exactly(student_1.id, student_2.id)
96+
end
97+
end
98+
99+
context 'when handling non-existent student IDs' do
100+
it 'skips non-existent students and processes valid ones' do
101+
non_existent_id = SecureRandom.uuid
102+
project_1 = create(:project, user_id: student_1.id)
103+
104+
delete "/api/schools/#{school.id}/students/batch",
105+
params: { student_ids: [student_1.id, non_existent_id] },
106+
headers: headers
107+
108+
expect(response).to have_http_status(:ok)
109+
110+
json = JSON.parse(response.body)
111+
expect(json['results'].size).to eq(2)
112+
113+
# Valid student should be removed
114+
valid_result = json['results'].find { |r| r['user_id'] == student_1.id }
115+
expect(valid_result['error']).to be_nil
116+
expect(Project.exists?(project_1.id)).to be false
117+
expect(ClassStudent.where(student_id: student_1.id)).not_to exist
118+
119+
# Non-existent student should be skipped
120+
skipped_result = json['results'].find { |r| r['user_id'] == non_existent_id }
121+
expect(skipped_result['skipped']).to be true
122+
expect(skipped_result['reason']).to eq('no_role_in_school')
123+
end
124+
125+
it 'returns success when all IDs are non-existent' do
126+
non_existent_id_1 = SecureRandom.uuid
127+
non_existent_id_2 = SecureRandom.uuid
128+
129+
delete "/api/schools/#{school.id}/students/batch",
130+
params: { student_ids: [non_existent_id_1, non_existent_id_2] },
131+
headers: headers
132+
133+
expect(response).to have_http_status(:ok)
134+
135+
json = JSON.parse(response.body)
136+
expect(json['results'].size).to eq(2)
137+
expect(json['results'].all? { |r| r['skipped'] == true }).to be true
138+
expect(json['results'].all? { |r| r['reason'] == 'no_role_in_school' }).to be true
139+
end
140+
end
141+
142+
context 'when handling students from different schools' do
143+
let(:other_school) { create(:school) }
144+
let(:other_school_owner) { create(:owner, school: other_school) }
145+
let(:other_student) { create(:student, school: other_school) }
146+
147+
before do
148+
other_school_class = create(:school_class, school: other_school)
149+
create(:class_student, student_id: other_student.id, school_class: other_school_class)
150+
end
151+
152+
it 'skips students from different schools and processes own students' do
153+
project_1 = create(:project, user_id: student_1.id)
154+
other_project = create(:project, user_id: other_student.id)
155+
156+
delete "/api/schools/#{school.id}/students/batch",
157+
params: { student_ids: [student_1.id, other_student.id] },
158+
headers: headers
159+
160+
expect(response).to have_http_status(:ok)
161+
162+
json = JSON.parse(response.body)
163+
expect(json['results'].size).to eq(2)
164+
165+
# Own student should be removed
166+
valid_result = json['results'].find { |r| r['user_id'] == student_1.id }
167+
expect(valid_result['error']).to be_nil
168+
expect(Project.exists?(project_1.id)).to be false
169+
expect(ClassStudent.where(student_id: student_1.id)).not_to exist
170+
171+
# Other school's student should be skipped
172+
skipped_result = json['results'].find { |r| r['user_id'] == other_student.id }
173+
expect(skipped_result['skipped']).to be true
174+
expect(skipped_result['reason']).to eq('no_role_in_school')
175+
176+
# Other school's student data should remain intact
177+
expect(Project.exists?(other_project.id)).to be true
178+
expect(Role.where(user_id: other_student.id, school_id: other_school.id, role: :student)).to exist
179+
end
180+
end
181+
182+
context 'when handling partial failures' do
183+
it 'returns 200 OK with error details when some deletions fail' do
184+
project_2 = create(:project, user_id: student_2.id)
185+
186+
# Simulate ProfileApiClient failure for one student
187+
allow(ProfileApiClient).to receive(:delete_school_student).with(
188+
token: UserProfileMock::TOKEN,
189+
school_id: school.id,
190+
student_id: student_1.id
191+
).and_raise(StandardError, 'Profile API error')
192+
193+
allow(ProfileApiClient).to receive(:delete_school_student).with(
194+
token: UserProfileMock::TOKEN,
195+
school_id: school.id,
196+
student_id: student_2.id
197+
).and_return(true)
198+
199+
delete "/api/schools/#{school.id}/students/batch",
200+
params: { student_ids: [student_1.id, student_2.id] },
201+
headers: headers
202+
203+
expect(response).to have_http_status(:ok)
204+
205+
json = JSON.parse(response.body)
206+
expect(json['results'].size).to eq(2)
207+
expect(json['error']).to eq('1 student(s) failed to be removed')
208+
209+
# First student should have error
210+
failed_result = json['results'].find { |r| r['user_id'] == student_1.id }
211+
expect(failed_result['error']).to match(/StandardError: Profile API error/)
212+
213+
# Second student should succeed
214+
success_result = json['results'].find { |r| r['user_id'] == student_2.id }
215+
expect(success_result['error']).to be_nil
216+
expect(Project.exists?(project_2.id)).to be false
217+
expect(ClassStudent.where(student_id: student_2.id)).not_to exist
218+
end
219+
220+
it 'reports correct error count when multiple deletions fail' do
221+
allow(ProfileApiClient).to receive(:delete_school_student).and_raise(StandardError, 'Profile API down')
222+
223+
delete "/api/schools/#{school.id}/students/batch",
224+
params: { student_ids: [student_1.id, student_2.id] },
225+
headers: headers
226+
227+
expect(response).to have_http_status(:ok)
228+
229+
json = JSON.parse(response.body)
230+
expect(json['results'].size).to eq(2)
231+
expect(json['error']).to eq('2 student(s) failed to be removed')
232+
expect(json['results'].all? { |r| r['error'].present? }).to be true
233+
end
234+
end
235+
end
236+
end

0 commit comments

Comments
 (0)