Skip to content

Commit c23c079

Browse files
authored
Create students in Profile API (#370)
2 parents 93977bc + 2d8ec2e commit c23c079

15 files changed

+632
-69
lines changed

app/controllers/api/school_students_controller.rb

+24
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ class SchoolStudentsController < ApiController
55
before_action :authorize_user
66
load_and_authorize_resource :school
77
authorize_resource :school_student, class: false
8+
before_action :create_safeguarding_flags
89

910
def index
1011
result = SchoolStudent::List.call(school: @school, token: current_user.token)
@@ -62,5 +63,28 @@ def destroy
6263
def school_student_params
6364
params.require(:school_student).permit(:username, :password, :name)
6465
end
66+
67+
def create_safeguarding_flags
68+
create_teacher_safeguarding_flag
69+
create_owner_safeguarding_flag
70+
end
71+
72+
def create_teacher_safeguarding_flag
73+
return unless current_user.school_teacher?(@school)
74+
75+
ProfileApiClient.create_safeguarding_flag(
76+
token: current_user.token,
77+
flag: ProfileApiClient::SAFEGUARDING_FLAGS[:teacher]
78+
)
79+
end
80+
81+
def create_owner_safeguarding_flag
82+
return unless current_user.school_owner?(@school)
83+
84+
ProfileApiClient.create_safeguarding_flag(
85+
token: current_user.token,
86+
flag: ProfileApiClient::SAFEGUARDING_FLAGS[:owner]
87+
)
88+
end
6589
end
6690
end

app/services/school_verification_service.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ def verify(token:)
1313
school.verify!
1414
Role.owner.create!(user_id: school.creator_id, school:)
1515
Role.teacher.create!(user_id: school.creator_id, school:)
16-
ProfileApiClient.create_school(token:, school:)
16+
ProfileApiClient.create_school(token:, id: school.id, code: school.code)
1717
end
1818
rescue StandardError => e
1919
Sentry.capture_exception(e)

lib/concepts/school_student/create.rb

+10-3
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ class Create
55
class << self
66
def call(school:, school_student_params:, token:)
77
response = OperationResponse.new
8-
create_student(school, school_student_params, token)
8+
response[:student_id] = create_student(school, school_student_params, token)
9+
response
10+
rescue ProfileApiClient::CreateStudent422Error => e
11+
Sentry.capture_exception(e)
12+
response[:error] = "Error creating school student: #{e.error}"
913
response
1014
rescue StandardError => e
1115
Sentry.capture_exception(e)
@@ -16,14 +20,17 @@ def call(school:, school_student_params:, token:)
1620
private
1721

1822
def create_student(school, school_student_params, token)
19-
organisation_id = school.id
23+
school_id = school.id
2024
username = school_student_params.fetch(:username)
2125
password = school_student_params.fetch(:password)
2226
name = school_student_params.fetch(:name)
2327

2428
validate(school:, username:, password:, name:)
2529

26-
ProfileApiClient.create_school_student(token:, username:, password:, name:, organisation_id:)
30+
response = ProfileApiClient.create_school_student(token:, username:, password:, name:, school_id:)
31+
user_id = response[:created].first
32+
Role.student.create!(school:, user_id:)
33+
user_id
2734
end
2835

2936
def validate(school:, username:, password:, name:)

lib/concepts/school_student/create_batch.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def create_batch(school, uploaded_file, token)
2323
validate(school:, sheet:)
2424

2525
non_header_rows_with_content(sheet:).each do |name, username, password|
26-
ProfileApiClient.create_school_student(token:, username:, password:, name:, organisation_id: school.id)
26+
ProfileApiClient.create_school_student(token:, username:, password:, name:, school_id: school.id)
2727
end
2828
end
2929

lib/profile_api_client.rb

+85-27
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,48 @@
11
# frozen_string_literal: true
22

33
class ProfileApiClient
4+
SAFEGUARDING_FLAGS = {
5+
teacher: 'school:teacher',
6+
owner: 'school:owner'
7+
}.freeze
8+
9+
class Error < StandardError; end
10+
11+
class CreateStudent422Error < Error
12+
DEFAULT_ERROR = 'unknown error'
13+
ERRORS = {
14+
'ERR_USER_EXISTS' => 'username has already been taken',
15+
'ERR_INVALID' => 'unknown validation error',
16+
'ERR_INVALID_PASSWORD' => 'password is invalid',
17+
'ERR_UNKNOWN' => DEFAULT_ERROR
18+
}.freeze
19+
20+
attr_reader :username, :error
21+
22+
def initialize(error)
23+
@username = error['username']
24+
@error = ERRORS.fetch(error['error'], DEFAULT_ERROR)
25+
26+
super "Student not created in Profile API (status code 422, username '#{@username}', error '#{@error}')"
27+
end
28+
end
29+
430
class << self
531
# TODO: Replace with HTTP requests once the profile API has been built.
632

7-
def create_school(token:, school:)
8-
return { 'id' => school.id, 'schoolCode' => school.code } if ENV['BYPASS_OAUTH'].present?
33+
def create_school(token:, id:, code:)
34+
return { 'id' => id, 'schoolCode' => code } if ENV['BYPASS_OAUTH'].present?
935

10-
response = connection.post('/api/v1/schools') do |request|
11-
apply_default_headers(request, token)
36+
response = connection(token).post('/api/v1/schools') do |request|
1237
request.body = {
13-
id: school.id,
14-
schoolCode: school.code
15-
}.to_json
38+
id:,
39+
schoolCode: code
40+
}
1641
end
1742

18-
raise "School not created in Profile API. HTTP response code: #{response.status}" unless response.status == 201
43+
raise "School not created in Profile API (status code #{response.status})" unless response.status == 201
1944

20-
JSON.parse(response.body)
45+
response.body
2146
end
2247

2348
# The API should enforce these constraints:
@@ -142,19 +167,24 @@ def list_school_students(token:, organisation_id:)
142167
# The API should respond:
143168
# - 404 Not Found if the user doesn't exist
144169
# - 422 Unprocessable if the constraints are not met
145-
def create_school_student(token:, username:, password:, name:, organisation_id:)
170+
# rubocop:disable Metrics/AbcSize
171+
def create_school_student(token:, username:, password:, name:, school_id:)
146172
return nil if token.blank?
147173

148-
_ = username
149-
_ = password
150-
_ = name
151-
_ = organisation_id
174+
response = connection(token).post("/api/v1/schools/#{school_id}/students") do |request|
175+
request.body = [{
176+
name: name.strip,
177+
username: username.strip,
178+
password: password.strip
179+
}]
180+
end
152181

153-
# TODO: We should make Faraday raise a Ruby error for a non-2xx status
154-
# code so that SchoolStudent::Create propagates the error in the response.
155-
response = {}
156-
response.deep_symbolize_keys
182+
raise CreateStudent422Error, response.body['errors'].first if response.status == 422
183+
raise "Student not created in Profile API (status code #{response.status})" unless response.status == 201
184+
185+
response.body.deep_symbolize_keys
157186
end
187+
# rubocop:enable Metrics/AbcSize
158188

159189
# The API should enforce these constraints:
160190
# - The token has the school-owner or school-teacher role for the given organisation ID
@@ -198,18 +228,46 @@ def delete_school_student(token:, student_id:, organisation_id:)
198228
response.deep_symbolize_keys
199229
end
200230

201-
private
231+
def safeguarding_flags(token:)
232+
response = connection(token).get('/api/v1/safeguarding-flags')
202233

203-
def connection
204-
Faraday.new(ENV.fetch('IDENTITY_URL'))
234+
unless response.status == 200
235+
raise "Safeguarding flags cannot be retrieved from Profile API (status code #{response.status})"
236+
end
237+
238+
response.body.map(&:deep_symbolize_keys)
239+
end
240+
241+
def create_safeguarding_flag(token:, flag:)
242+
response = connection(token).post('/api/v1/safeguarding-flags') do |request|
243+
request.body = { flag: }
244+
end
245+
246+
return if response.status == 201 || response.status == 303
247+
248+
raise "Safeguarding flag not created in Profile API (status code #{response.status})"
205249
end
206250

207-
def apply_default_headers(request, token)
208-
request.headers['Accept'] = 'application/json'
209-
request.headers['Authorization'] = "Bearer #{token}"
210-
request.headers['Content-Type'] = 'application/json'
211-
request.headers['X-API-KEY'] = ENV.fetch('PROFILE_API_KEY')
212-
request
251+
def delete_safeguarding_flag(token:, flag:)
252+
response = connection(token).delete("/api/v1/safeguarding-flags/#{flag}")
253+
254+
return if response.status == 204
255+
256+
raise "Safeguarding flag not deleted from Profile API (status code #{response.status})"
257+
end
258+
259+
private
260+
261+
def connection(token)
262+
Faraday.new(ENV.fetch('IDENTITY_URL')) do |faraday|
263+
faraday.request :json
264+
faraday.response :json
265+
faraday.headers = {
266+
'Accept' => 'application/json',
267+
'Authorization' => "Bearer #{token}",
268+
'X-API-KEY' => ENV.fetch('PROFILE_API_KEY')
269+
}
270+
end
213271
end
214272
end
215273
end

spec/concepts/school_student/create_batch_spec.rb

+2-2
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,15 @@
2121

2222
# TODO: Replace with WebMock assertion once the profile API has been built.
2323
expect(ProfileApiClient).to have_received(:create_school_student)
24-
.with(token:, username: 'jane123', password: 'secret123', name: 'Jane Doe', organisation_id: school.id)
24+
.with(token:, username: 'jane123', password: 'secret123', name: 'Jane Doe', school_id: school.id)
2525
end
2626

2727
it "makes a profile API call to create John Doe's account" do
2828
described_class.call(school:, uploaded_file: file, token:)
2929

3030
# TODO: Replace with WebMock assertion once the profile API has been built.
3131
expect(ProfileApiClient).to have_received(:create_school_student)
32-
.with(token:, username: 'john123', password: 'secret456', name: 'John Doe', organisation_id: school.id)
32+
.with(token:, username: 'john123', password: 'secret456', name: 'John Doe', school_id: school.id)
3333
end
3434

3535
context 'when an .xlsx file is provided' do

spec/concepts/school_student/create_spec.rb

+41-4
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
RSpec.describe SchoolStudent::Create, type: :unit do
66
let(:token) { UserProfileMock::TOKEN }
77
let(:school) { create(:verified_school) }
8+
let(:user_id) { SecureRandom.uuid }
89

910
let(:school_student_params) do
1011
{
@@ -15,7 +16,7 @@
1516
end
1617

1718
before do
18-
stub_profile_api_create_school_student
19+
stub_profile_api_create_school_student(user_id:)
1920
end
2021

2122
it 'returns a successful operation response' do
@@ -28,13 +29,23 @@
2829

2930
# TODO: Replace with WebMock assertion once the profile API has been built.
3031
expect(ProfileApiClient).to have_received(:create_school_student)
31-
.with(token:, username: 'student-to-create', password: 'at-least-8-characters', name: 'School Student', organisation_id: school.id)
32+
.with(token:, username: 'student-to-create', password: 'at-least-8-characters', name: 'School Student', school_id: school.id)
33+
end
34+
35+
it 'creates a role associating the student with the school' do
36+
described_class.call(school:, school_student_params:, token:)
37+
expect(Role.student.where(school:, user_id:)).to exist
38+
end
39+
40+
it 'returns the ID of the student created in Profile API' do
41+
response = described_class.call(school:, school_student_params:, token:)
42+
expect(response[:student_id]).to eq(user_id)
3243
end
3344

3445
context 'when creation fails' do
3546
let(:school_student_params) do
3647
{
37-
username: ' ',
48+
username: '',
3849
password: 'at-least-8-characters',
3950
name: 'School Student'
4051
}
@@ -56,7 +67,7 @@
5667

5768
it 'returns the error message in the operation response' do
5869
response = described_class.call(school:, school_student_params:, token:)
59-
expect(response[:error]).to match(/username ' ' is invalid/)
70+
expect(response[:error]).to match(/username '' is invalid/)
6071
end
6172

6273
it 'sent the exception to Sentry' do
@@ -73,4 +84,30 @@
7384
expect(response[:error]).to match(/school is not verified/)
7485
end
7586
end
87+
88+
context 'when the student cannot be created in profile api because of a 422 response' do
89+
let(:error) { { 'username' => 'username', 'error' => 'ERR_USER_EXISTS' } }
90+
let(:exception) { ProfileApiClient::CreateStudent422Error.new(error) }
91+
92+
before do
93+
allow(ProfileApiClient).to receive(:create_school_student).and_raise(exception)
94+
end
95+
96+
it 'adds a useful error message' do
97+
response = described_class.call(school:, school_student_params:, token:)
98+
expect(response[:error]).to eq('Error creating school student: username has already been taken')
99+
end
100+
end
101+
102+
context 'when the student cannot be created in profile api because of a response other than 422' do
103+
before do
104+
allow(ProfileApiClient).to receive(:create_school_student)
105+
.and_raise('Student not created in Profile API (status code 401)')
106+
end
107+
108+
it 'adds a useful error message' do
109+
response = described_class.call(school:, school_student_params:, token:)
110+
expect(response[:error]).to eq('Error creating school student: Student not created in Profile API (status code 401)')
111+
end
112+
end
76113
end

spec/features/school_student/creating_a_batch_of_school_students_spec.rb

+27
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
before do
77
authenticated_in_hydra_as(owner)
88
stub_profile_api_create_school_student
9+
stub_profile_api_create_safeguarding_flag
910
end
1011

1112
let(:headers) { { Authorization: UserProfileMock::TOKEN } }
@@ -15,6 +16,16 @@
1516

1617
let(:file) { fixture_file_upload('students.csv') }
1718

19+
it 'creates the school owner safeguarding flag' do
20+
post("/api/schools/#{school.id}/students/batch", headers:, params: { file: })
21+
expect(ProfileApiClient).to have_received(:create_safeguarding_flag).with(token: UserProfileMock::TOKEN, flag: ProfileApiClient::SAFEGUARDING_FLAGS[:owner])
22+
end
23+
24+
it 'does not create the school teacher safeguarding flag' do
25+
post("/api/schools/#{school.id}/students/batch", headers:, params: { file: })
26+
expect(ProfileApiClient).not_to have_received(:create_safeguarding_flag).with(token: UserProfileMock::TOKEN, flag: ProfileApiClient::SAFEGUARDING_FLAGS[:teacher])
27+
end
28+
1829
it 'responds 204 No Content' do
1930
post("/api/schools/#{school.id}/students/batch", headers:, params: { file: })
2031
expect(response).to have_http_status(:no_content)
@@ -28,6 +39,22 @@
2839
expect(response).to have_http_status(:no_content)
2940
end
3041

42+
it 'does not create the school owner safeguarding flag when the user is a school-teacher' do
43+
teacher = create(:teacher, school:)
44+
authenticated_in_hydra_as(teacher)
45+
46+
post("/api/schools/#{school.id}/students/batch", headers:, params: { file: })
47+
expect(ProfileApiClient).not_to have_received(:create_safeguarding_flag).with(token: UserProfileMock::TOKEN, flag: ProfileApiClient::SAFEGUARDING_FLAGS[:owner])
48+
end
49+
50+
it 'creates the school teacher safeguarding flag when the user is a school-teacher' do
51+
teacher = create(:teacher, school:)
52+
authenticated_in_hydra_as(teacher)
53+
54+
post("/api/schools/#{school.id}/students/batch", headers:, params: { file: })
55+
expect(ProfileApiClient).to have_received(:create_safeguarding_flag).with(token: UserProfileMock::TOKEN, flag: ProfileApiClient::SAFEGUARDING_FLAGS[:teacher])
56+
end
57+
3158
it 'responds 422 Unprocessable Entity when params are invalid' do
3259
post("/api/schools/#{school.id}/students/batch", headers:, params: {})
3360
expect(response).to have_http_status(:unprocessable_entity)

0 commit comments

Comments
 (0)