Skip to content

Commit b9f7ee2

Browse files
319-bulk-student-validation (#458)
## Status - Closes RaspberryPiFoundation/digital-editor-issues#319 ## What's changed? - Handles validations from profile when creating bulk students ## Steps to perform after deploying to production N/A --------- Co-authored-by: Conor <[email protected]>
1 parent 051cf7f commit b9f7ee2

File tree

17 files changed

+255
-242
lines changed

17 files changed

+255
-242
lines changed

Diff for: .devcontainer/devcontainer.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@
6666
"codezombiech.gitignore",
6767
"shopify.ruby-lsp",
6868
"koichisasada.vscode-rdbg",
69-
"rangav.vscode-thunder-client",
69+
"postman.postman-for-vscode",
7070
"ninoseki.vscode-mogami"
7171
],
7272
"settings": {

Diff for: app/controllers/api/school_students_controller.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def create_batch
3737
)
3838

3939
if result.success?
40-
head :no_content
40+
head :accepted
4141
else
4242
render json: { error: result[:error] }, status: :unprocessable_entity
4343
end

Diff for: app/controllers/api/user_jobs_controller.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ def index
1515
end
1616

1717
def show
18-
user_job = UserJob.find_by(job_id: params[:id], teacher_id: current_user.id)
18+
user_job = UserJob.find_by(good_job_id: params[:id], user_id: current_user.id)
1919
job = job_attributes(user_job.good_job)
2020
if job
2121
render json: { job: }, status: :ok

Diff for: app/jobs/create_students_job.rb

+1-5
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,7 @@ def self.attempt_perform_later(school_id:, students:, token:, user_id:)
2828
end
2929

3030
def perform(school_id:, students:, token:)
31-
students = Array(students).map do |student|
32-
student[:password] = DecryptionHelpers.decrypt_password(student[:password])
33-
student
34-
end
35-
31+
students.each { |student| student[:password] = DecryptionHelpers.decrypt_password(student[:password]) }
3632
responses = ProfileApiClient.create_school_students(token:, students:, school_id:)
3733
return if responses[:created].blank?
3834

Diff for: app/models/user_job.rb

+2
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,7 @@
33
class UserJob < ApplicationRecord
44
belongs_to :good_job, class_name: 'GoodJob::Job'
55

6+
validates :user_id, presence: true
7+
68
attr_accessor :user
79
end

Diff for: config/locales/en.yml

+9-7
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
en:
22
errors:
33
admin:
4-
unauthorized: 'Not authorized.'
4+
unauthorized: "Not authorized."
55
project:
66
editing:
7-
delete_default_component: 'Cannot delete default file'
8-
change_default_name: 'Cannot amend default file name'
9-
change_default_extension: 'Cannot amend default file extension'
7+
delete_default_component: "Cannot delete default file"
8+
change_default_name: "Cannot amend default file name"
9+
change_default_extension: "Cannot amend default file extension"
1010
remixing:
11-
invalid_params: 'Invalid parameters'
12-
cannot_save: 'Cannot create project remix'
11+
invalid_params: "Invalid parameters"
12+
cannot_save: "Cannot create project remix"
1313
validations:
1414
school:
15-
website: 'must be a valid URL'
15+
website: "must be a valid URL"
1616
invitation:
1717
email_address: "'%<value>s' is invalid"
18+
school_student:
19+
not_empty: "You must supply a %{field}"

Diff for: lib/concepts/school_student/create_batch.rb

+45-12
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,71 @@
11
# frozen_string_literal: true
22

33
module SchoolStudent
4+
class Error < StandardError; end
5+
6+
class ValidationError < StandardError
7+
attr_reader :errors
8+
9+
def initialize(errors)
10+
@errors = errors
11+
super()
12+
end
13+
end
14+
415
class CreateBatch
516
class << self
617
def call(school:, school_students_params:, token:, user_id:)
718
response = OperationResponse.new
819
response[:job_id] = create_batch(school, school_students_params, token, user_id)
920
response
21+
rescue ValidationError => e
22+
response[:error] = e.errors
23+
response
1024
rescue StandardError => e
1125
Sentry.capture_exception(e)
12-
response[:error] = e
26+
response[:error] = "Error creating school students: #{e}"
1327
response
1428
end
1529

1630
private
1731

1832
def create_batch(school, students, token, user_id)
19-
validate(students:)
20-
# TODO: Do the preflight checks here
33+
validate(school:, students:, token:)
2134

2235
job = CreateStudentsJob.attempt_perform_later(school_id: school.id, students:, token:, user_id:)
2336
job&.job_id
2437
end
2538

26-
def validate(students:)
27-
errors = []
28-
students.each_with_index do |student, n|
29-
student_errors = []
30-
student_errors << "username '#{student[:username]}' is invalid" if student[:username].blank?
31-
student_errors << "password '#{student[:password]}' is invalid" if student[:password].blank?
32-
student_errors << "name '#{student[:name]}' is invalid" if student[:name].blank?
33-
errors << "Error creating student #{n + 1}: #{student_errors.join(', ')}" unless student_errors.empty?
39+
def validate(school:, students:, token:)
40+
decrypted_students = decrypt_students(students)
41+
ProfileApiClient.create_school_students(token:, students: decrypted_students, school_id: school.id, preflight: true)
42+
rescue ProfileApiClient::Student422Error => e
43+
handle_student422_error(e.errors)
44+
end
45+
46+
def decrypt_students(students)
47+
students.deep_dup.each do |student|
48+
student[:password] = DecryptionHelpers.decrypt_password(student[:password])
3449
end
35-
raise ArgumentError, errors.join(', ') unless errors.empty?
50+
end
51+
52+
def handle_student422_error(errors)
53+
formatted_errors = errors.each_with_object({}) do |error, hash|
54+
username = error['username'] || error['path']
55+
field = error['path'].split('.').last
56+
57+
hash[username] ||= []
58+
hash[username] << I18n.t(
59+
"validations.school_student.#{error['errorCode'].underscore}",
60+
field:,
61+
default: error['message']
62+
)
63+
64+
# Ensure uniqueness to avoid repeat errors with duplicate usernames
65+
hash[username] = hash[username].uniq
66+
end
67+
68+
raise ValidationError, formatted_errors unless formatted_errors.nil? || formatted_errors.blank?
3669
end
3770
end
3871
end

Diff for: lib/profile_api_client.rb

+19-9
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,16 @@ class ProfileApiClient
1212

1313
class Error < StandardError; end
1414

15-
class Student422Error < Error
16-
def initialize(error)
17-
@message = error['message']
18-
super @message
15+
class Student422Error < StandardError
16+
attr_reader :errors
17+
18+
def initialize(errors)
19+
@errors = errors
20+
if errors.is_a?(Hash)
21+
super(errors['message'])
22+
else
23+
super()
24+
end
1925
end
2026
end
2127

@@ -101,19 +107,23 @@ def create_school_student(token:, username:, password:, name:, school_id:)
101107
raise Student422Error, JSON.parse(e.response_body)['errors'].first
102108
end
103109

104-
def create_school_students(token:, students:, school_id:)
110+
def create_school_students(token:, students:, school_id:, preflight: false)
105111
return nil if token.blank?
106112

107113
students = Array(students)
108-
response = connection(token).post("/api/v1/schools/#{school_id}/students") do |request|
109-
request.body = students
114+
endpoint = "/api/v1/schools/#{school_id}/students"
115+
endpoint += '/preflight' if preflight
116+
response = connection(token).post(endpoint) do |request|
117+
request.body = students.to_json
118+
request.headers['Content-Type'] = 'application/json'
110119
end
111120

112-
raise UnexpectedResponse, response unless response.status == 201
121+
raise UnexpectedResponse, response unless [200, 201].include?(response.status)
113122

114123
response.body.deep_symbolize_keys
115124
rescue Faraday::BadRequestError => e
116-
raise Student422Error, JSON.parse(e.response_body)['errors'].first
125+
errors = JSON.parse(e.response_body)['errors']
126+
raise Student422Error, errors
117127
end
118128

119129
def update_school_student(token:, school_id:, student_id:, name: nil, username: nil, password: nil) # rubocop:disable Metrics/ParameterLists

Diff for: spec/concepts/school_student/create_batch_spec.rb

+33-14
Original file line numberDiff line numberDiff line change
@@ -11,31 +11,36 @@
1111
[
1212
{
1313
username: 'student-to-create',
14-
password: 'at-least-8-characters',
14+
password: 'SaoXlDBAyiAFoMH3VsddhdA7JWnM8P8by1wOjBUWH2g=',
1515
name: 'School Student'
1616
},
1717
{
1818
username: 'second-student-to-create',
19-
password: 'at-least-8-characters',
19+
password: 'SaoXlDBAyiAFoMH3VsddhdA7JWnM8P8by1wOjBUWH2g=',
2020
name: 'School Student 2'
2121
}
2222
]
2323
end
2424

2525
before do
26-
stub_profile_api_create_school_students(user_ids: [SecureRandom.uuid, SecureRandom.uuid])
27-
2826
ActiveJob::Base.queue_adapter = :test
2927
end
3028

31-
it 'queues CreateStudentsJob' do
32-
expect do
33-
described_class.call(school:, school_students_params:, token:, user_id:)
34-
end.to have_enqueued_job(CreateStudentsJob).with(school_id: school.id, students: school_students_params, token:)
29+
context 'when queuing a job' do
30+
before do
31+
stub_profile_api_create_school_students(user_ids: [SecureRandom.uuid, SecureRandom.uuid])
32+
end
33+
34+
it 'queues CreateStudentsJob' do
35+
expect do
36+
described_class.call(school:, school_students_params:, token:, user_id:)
37+
end.to have_enqueued_job(CreateStudentsJob).with(school_id: school.id, students: school_students_params, token:)
38+
end
3539
end
3640

3741
context 'when a job has been queued' do
3842
before do
43+
stub_profile_api_create_school_students(user_ids: [SecureRandom.uuid, SecureRandom.uuid])
3944
allow(CreateStudentsJob).to receive(:attempt_perform_later).and_return(
4045
instance_double(CreateStudentsJob, job_id: SecureRandom.uuid)
4146
)
@@ -52,23 +57,24 @@
5257
end
5358
end
5459

55-
context 'when validation fails' do
60+
context 'when a normal error occurs' do
5661
let(:school_students_params) do
5762
[
5863
{
59-
username: '',
60-
password: 'at-least-8-characters',
64+
username: 'a-student',
65+
password: 'Password',
6166
name: 'School Student'
6267
},
6368
{
6469
username: 'second-student-to-create',
65-
password: 'at-least-8-characters',
70+
password: 'Password',
6671
name: 'School Student 2'
6772
}
6873
]
6974
end
7075

7176
before do
77+
stub_profile_api_create_school_students(user_ids: [SecureRandom.uuid, SecureRandom.uuid])
7278
allow(Sentry).to receive(:capture_exception)
7379
end
7480

@@ -85,13 +91,26 @@
8591

8692
it 'returns the error message in the operation response' do
8793
response = described_class.call(school:, school_students_params:, token:, user_id:)
88-
error_message = response[:error].message
89-
expect(error_message).to match(/Error creating student 1: username '' is invalid/)
94+
error_message = response[:error]
95+
expect(error_message).to match(/Error creating school students: Decryption failed: iv must be 16 bytes/)
9096
end
9197

9298
it 'sent the exception to Sentry' do
9399
described_class.call(school:, school_students_params:, token:, user_id:)
94100
expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError))
95101
end
96102
end
103+
104+
context 'when a validation error occurs' do
105+
before do
106+
stub_profile_api_create_school_students_validation_error
107+
end
108+
109+
it 'returns the expected formatted errors' do
110+
response = described_class.call(school:, school_students_params:, token:, user_id:)
111+
expect(response[:error]).to eq(
112+
{ 'student-to-create' => ['Username must be unique in the batch data', 'Password is too simple (it should not be easily guessable, <a href="https://my.raspberrypi.org/password-help">need password help?</a>)', 'You must supply a name'], 'another-student-to-create-2' => ['Password must be at least 8 characters', 'You must supply a name'] }
113+
)
114+
end
115+
end
97116
end

Diff for: spec/factories/good_job.rb

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# frozen_string_literal: true
2+
3+
FactoryBot.define do
4+
factory :good_job, class: 'GoodJob::Job' do
5+
# Add necessary attributes here
6+
queue_name { 'default' }
7+
priority { 0 }
8+
serialized_params { {} }
9+
scheduled_at { Time.current }
10+
performed_at { nil }
11+
finished_at { nil }
12+
error { nil }
13+
end
14+
end

Diff for: spec/factories/user_job.rb

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# frozen_string_literal: true
2+
3+
FactoryBot.define do
4+
factory :user_job do
5+
association :good_job, factory: :good_job
6+
user_id { SecureRandom.uuid }
7+
end
8+
end

0 commit comments

Comments
 (0)