Skip to content

Commit 83b718a

Browse files
authored
Class-member-create-allow-multiple-student-ids (#392)
## What's changed? - Additional class_members endpoint for batch creation - both create and create_batch use the same create concept - class members create methods first check for the students in profile via `SchoolStudent::List` - the create concept utilises the `student` instance over ID strings - the `student` attributes are nested in the `class_members` response - failed student class member creations are added to an `errors` object which is returned along with the successful creations
2 parents 3a3f0e7 + 157e84f commit 83b718a

File tree

11 files changed

+372
-184
lines changed

11 files changed

+372
-184
lines changed

app/controllers/api/class_members_controller.rb

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,24 @@ def index
2222
end
2323

2424
def create
25-
result = ClassMember::Create.call(school_class: @school_class, class_member_params:)
25+
student_ids = [class_member_params[:student_id]]
26+
students = SchoolStudent::List.call(school: @school, token: current_user.token, student_ids:)
27+
result = ClassMember::Create.call(school_class: @school_class, students: students[:school_students])
2628

2729
if result.success?
28-
@class_member_with_student = result[:class_member].with_student
30+
@class_member = result[:class_members].first
31+
render :show, formats: [:json], status: :created
32+
else
33+
render json: { error: result[:error] }, status: :unprocessable_entity
34+
end
35+
end
36+
37+
def create_batch
38+
students = SchoolStudent::List.call(school: @school, token: current_user.token, student_ids: create_batch_params)
39+
result = ClassMember::Create.call(school_class: @school_class, students: students[:school_students])
40+
41+
if result.success?
42+
@class_members = result[:class_members]
2943
render :show, formats: [:json], status: :created
3044
else
3145
render json: { error: result[:error] }, status: :unprocessable_entity
@@ -45,7 +59,11 @@ def destroy
4559
private
4660

4761
def class_member_params
48-
params.require(:class_member).permit(:student_id, student_ids: [])
62+
params.require(:class_member).permit(:student_id)
63+
end
64+
65+
def create_batch_params
66+
params.permit(student_ids: [])
4967
end
5068
end
5169
end

app/models/ability.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ def define_school_owner_abilities(school:)
5151
can(%i[read update destroy], School, id: school.id)
5252
can(%i[read create update destroy], SchoolClass, school: { id: school.id })
5353
can(%i[read], Project, school_id: school.id, lesson: { visibility: %w[teachers students] })
54-
can(%i[read create destroy], ClassMember, school_class: { school: { id: school.id } })
54+
can(%i[read create create_batch destroy], ClassMember, school_class: { school: { id: school.id } })
5555
can(%i[read create destroy], :school_owner)
5656
can(%i[read create destroy], :school_teacher)
5757
can(%i[read create create_batch update destroy], :school_student)
@@ -66,7 +66,8 @@ def define_school_teacher_abilities(user:, school:)
6666
can(%i[create], SchoolClass, school: { id: school.id })
6767
can(%i[read update destroy], SchoolClass, school: { id: school.id }, teacher_id: user.id)
6868
can(%i[read], Project, school_id: school.id, lesson: { visibility: %w[teachers students] })
69-
can(%i[read create destroy], ClassMember, school_class: { school: { id: school.id }, teacher_id: user.id })
69+
can(%i[read create create_batch destroy], ClassMember,
70+
school_class: { school: { id: school.id }, teacher_id: user.id })
7071
can(%i[read], :school_owner)
7172
can(%i[read], :school_teacher)
7273
can(%i[read create create_batch update], :school_student)

app/models/class_member.rb

Lines changed: 4 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
class ClassMember < ApplicationRecord
44
belongs_to :school_class
55
delegate :school, to: :school_class
6+
attr_accessor :student
67

78
validates :student_id, presence: true, uniqueness: {
89
scope: :school_class_id,
@@ -11,28 +12,14 @@ class ClassMember < ApplicationRecord
1112

1213
validate :student_has_the_school_student_role_for_the_school
1314

14-
def self.students
15-
User.from_userinfo(ids: pluck(:student_id))
16-
end
17-
18-
def self.with_students
19-
by_id = students.index_by(&:id)
20-
all.map { |instance| [instance, by_id[instance.student_id]] }
21-
end
22-
23-
def with_student
24-
[self, User.from_userinfo(ids: student_id).first]
25-
end
26-
2715
private
2816

2917
def student_has_the_school_student_role_for_the_school
30-
return unless student_id_changed? && errors.blank?
18+
return unless student_id_changed? && errors.blank? && student.present?
3119

32-
user = User.new(id: student_id)
33-
return if user.school_student?(school)
20+
return if student.school_student?(school)
3421

35-
msg = "'#{student_id}' does not have the 'school-student' role for organisation '#{school.id}'"
22+
msg = "'#{student.id}' does not have the 'school-student' role for organisation '#{school.id}'"
3623
errors.add(:student, msg)
3724
end
3825
end
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# frozen_string_literal: true
2+
3+
json.call(
4+
class_member,
5+
:id,
6+
:school_class_id,
7+
:student_id,
8+
:created_at,
9+
:updated_at
10+
)
11+
12+
if class_member.student.present?
13+
json.set! :student do
14+
json.call(
15+
class_member.student,
16+
:id,
17+
:username,
18+
:name
19+
)
20+
end
21+
end
Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,11 @@
11
# frozen_string_literal: true
22

3-
class_member, student = @class_member_with_student
4-
5-
json.call(
6-
class_member,
7-
:id,
8-
:school_class_id,
9-
:student_id,
10-
:created_at,
11-
:updated_at
12-
)
13-
14-
json.student_username(student&.username)
15-
json.student_name(student&.name)
3+
if defined?(@class_members)
4+
json.array! @class_members do |class_member|
5+
json.partial! 'class_member', class_member:
6+
end
7+
elsif defined?(@class_member)
8+
json.class_member do
9+
json.partial! 'class_member', class_member: @class_member
10+
end
11+
end

config/routes.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@
4242
resource :school, only: [:show], controller: 'my_school'
4343
resources :schools, only: %i[index show create update destroy] do
4444
resources :classes, only: %i[index show create update destroy], controller: 'school_classes' do
45-
resources :members, only: %i[index create destroy], controller: 'class_members'
45+
resources :members, only: %i[index create destroy], controller: 'class_members' do
46+
post :batch, on: :collection, to: 'class_members#create_batch'
47+
end
4648
end
4749

4850
resources :owners, only: %i[index create destroy], controller: 'school_owners'

lib/concepts/class_member/operations/create.rb

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,39 @@
33
class ClassMember
44
class Create
55
class << self
6-
def call(school_class:, class_member_params:)
6+
def call(school_class:, students:)
77
response = OperationResponse.new
8-
response[:class_member] = school_class.members.build(class_member_params)
9-
response[:class_member].save!
8+
response[:class_members] = []
9+
response[:errors] = {}
10+
raise ArgumentError, 'No valid students provided' if students.blank?
11+
12+
create_class_members(school_class:, students:, response:)
1013
response
1114
rescue StandardError => e
1215
Sentry.capture_exception(e)
13-
errors = response[:class_member].errors.full_messages.join(',')
14-
response[:error] = "Error creating class member: #{errors}"
16+
response[:error] = "Error creating class members: #{e.message}"
1517
response
1618
end
19+
20+
private
21+
22+
def create_class_members(school_class:, students:, response:)
23+
students.each do |student|
24+
class_member = school_class.members.build({ student_id: student.id })
25+
class_member.student = student
26+
class_member.save!
27+
response[:class_members] << class_member
28+
rescue StandardError => e
29+
handle_class_member_error(e, class_member, student, response)
30+
response
31+
end
32+
end
33+
34+
def handle_class_member_error(exception, class_member, student, response)
35+
Sentry.capture_exception(exception)
36+
errors = class_member.errors.full_messages.join(',')
37+
response[:errors][student.id] = "Error creating class member for student_id #{student.id}: #{errors}"
38+
end
1739
end
1840
end
1941
end

spec/concepts/class_member/create_spec.rb

Lines changed: 106 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -5,61 +5,138 @@
55
RSpec.describe ClassMember::Create, type: :unit do
66
let!(:school_class) { create(:school_class, teacher_id: teacher.id, school:) }
77
let(:school) { create(:school) }
8-
let(:student) { create(:student, school:) }
8+
let(:students) { create_list(:student, 3, school:) }
99
let(:teacher) { create(:teacher, school:) }
1010

11-
let(:class_member_params) do
12-
{ student_id: student.id }
13-
end
11+
let(:student_ids) { students.map(&:id) }
1412

1513
it 'returns a successful operation response' do
16-
response = described_class.call(school_class:, class_member_params:)
14+
response = described_class.call(school_class:, students:)
1715
expect(response.success?).to be(true)
1816
end
1917

2018
it 'creates a school class' do
21-
expect { described_class.call(school_class:, class_member_params:) }.to change(ClassMember, :count).by(1)
19+
expect { described_class.call(school_class:, students:) }.to change(ClassMember, :count).by(3)
20+
end
21+
22+
it 'returns a class members JSON array' do
23+
response = described_class.call(school_class:, students:)
24+
expect(response[:class_members].size).to eq(3)
2225
end
2326

24-
it 'returns the class member in the operation response' do
25-
response = described_class.call(school_class:, class_member_params:)
26-
expect(response[:class_member]).to be_a(ClassMember)
27+
it 'returns class members in the operation response' do
28+
response = described_class.call(school_class:, students:)
29+
expect(response[:class_members]).to all(be_a(ClassMember))
2730
end
2831

2932
it 'assigns the school_class' do
30-
response = described_class.call(school_class:, class_member_params:)
31-
expect(response[:class_member].school_class).to eq(school_class)
33+
response = described_class.call(school_class:, students:)
34+
expect(response[:class_members]).to all(have_attributes(school_class:))
3235
end
3336

3437
it 'assigns the student_id' do
35-
response = described_class.call(school_class:, class_member_params:)
36-
expect(response[:class_member].student_id).to eq(student.id)
38+
response = described_class.call(school_class:, students:)
39+
expect(response[:class_members].map(&:student_id)).to match_array(student_ids)
3740
end
3841

39-
context 'when creation fails' do
40-
let(:class_member_params) { {} }
41-
42+
context 'when creations fail' do
4243
before do
4344
allow(Sentry).to receive(:capture_exception)
4445
end
4546

46-
it 'does not create a class member' do
47-
expect { described_class.call(school_class:, class_member_params:) }.not_to change(ClassMember, :count)
48-
end
47+
context 'with malformed students' do
48+
let(:students) { nil }
4949

50-
it 'returns a failed operation response' do
51-
response = described_class.call(school_class:, class_member_params:)
52-
expect(response.failure?).to be(true)
53-
end
50+
it 'does not create a class member' do
51+
expect { described_class.call(school_class:, students:) }.not_to change(ClassMember, :count)
52+
end
5453

55-
it 'returns the error message in the operation response' do
56-
response = described_class.call(school_class:, class_member_params:)
57-
expect(response[:error]).to match(/Error creating class member/)
54+
it 'returns a failed operation response' do
55+
response = described_class.call(school_class:, students:)
56+
expect(response.failure?).to be(true)
57+
end
58+
59+
it 'returns the error message in the operation response' do
60+
response = described_class.call(school_class:, students:)
61+
expect(response[:error]).to match(/No valid students provided/)
62+
end
63+
64+
it 'sent the exception to Sentry' do
65+
described_class.call(school_class:, students:)
66+
expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError))
67+
end
5868
end
5969

60-
it 'sent the exception to Sentry' do
61-
described_class.call(school_class:, class_member_params:)
62-
expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError))
70+
context 'with a student from a different school' do
71+
let(:different_school) { create(:school) }
72+
let(:different_school_student) { create(:student, school: different_school) }
73+
74+
context 'with non existent students' do
75+
let(:students) { [different_school_student] }
76+
77+
it 'does not create a class member' do
78+
expect { described_class.call(school_class:, students:) }.not_to change(ClassMember, :count)
79+
end
80+
81+
it 'returns a successful operation response' do
82+
response = described_class.call(school_class:, students:)
83+
expect(response.success?).to be(true)
84+
end
85+
86+
it 'returns an empty class members array' do
87+
response = described_class.call(school_class:, students:)
88+
expect(response[:class_members]).to eq([])
89+
end
90+
91+
it 'returns the error messages in the operation response' do
92+
response = described_class.call(school_class:, students:)
93+
expect(response[:errors][different_school_student.id]).to include("Error creating class member for student_id #{different_school_student.id}: Student '#{different_school_student.id}' does not have the 'school-student' role for organisation '#{school.id}'")
94+
end
95+
96+
it 'sent the exception to Sentry' do
97+
described_class.call(school_class:, students:)
98+
expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError))
99+
end
100+
end
101+
102+
context 'when one creation fails' do
103+
let(:new_students) { students + [different_school_student] }
104+
105+
it 'returns a successful operation response' do
106+
response = described_class.call(school_class:, students: new_students)
107+
expect(response.success?).to be(true)
108+
end
109+
110+
it 'returns a class members JSON array' do
111+
response = described_class.call(school_class:, students: new_students)
112+
expect(response[:class_members].size).to eq(3)
113+
end
114+
115+
it 'returns class members in the operation response' do
116+
response = described_class.call(school_class:, students: new_students)
117+
expect(response[:class_members]).to all(be_a(ClassMember))
118+
end
119+
120+
it 'assigns the school_class' do
121+
response = described_class.call(school_class:, students: new_students)
122+
expect(response[:class_members]).to all(have_attributes(school_class:))
123+
end
124+
125+
it 'assigns the successful students' do
126+
response = described_class.call(school_class:, students: new_students)
127+
expect(response[:class_members].map(&:student_id)).to match_array(student_ids)
128+
end
129+
130+
it 'returns the error messages in the operation response' do
131+
response = described_class.call(school_class:, students: new_students)
132+
expect(response[:errors][different_school_student.id]).to eq("Error creating class member for student_id #{different_school_student.id}: Student '#{different_school_student.id}' does not have the 'school-student' role for organisation '#{school.id}'")
133+
end
134+
135+
it 'sent the exception to Sentry' do
136+
described_class.call(school_class:, students: new_students)
137+
expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError))
138+
end
139+
end
63140
end
64141
end
65142
end

0 commit comments

Comments
 (0)