Skip to content

Commit 62a6a8d

Browse files
Add bulk lesson creation via lesson_projects
Expand the LessonsController#create to accept a lesson_projects array in the request body and create multiple lessons in one request. Add bulk_create_params to the LessonsController, permitting origin_identifier on each entry so the caller can correlate responses with the original projects. Add Lesson::CreateBulk to create individual Lessons and echo origin_identifier on each result.
1 parent 18cfb66 commit 62a6a8d

5 files changed

Lines changed: 319 additions & 8 deletions

File tree

app/controllers/api/lessons_controller.rb

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,19 @@ def show
3030
end
3131

3232
def create
33-
result = Lesson::Create.call(lesson_params: create_params)
34-
35-
if result.success?
36-
@lesson_with_user = result[:lesson].with_user
37-
render :show, formats: [:json], status: :created
33+
if params[:lesson_projects].present?
34+
@results = Lesson::CreateBulk.call(
35+
lessons_params: params[:lesson_projects].map { |entry| bulk_create_params(entry) }
36+
)
37+
render :create_bulk, formats: [:json], status: :created
3838
else
39-
render json: { error: result[:error] }, status: :unprocessable_content
39+
result = Lesson::Create.call(lesson_params: create_params)
40+
if result.success?
41+
@lesson_with_user = result[:lesson].with_user
42+
render :show, formats: [:json], status: :created
43+
else
44+
render json: { error: result[:error] }, status: :unprocessable_content
45+
end
4046
end
4147
end
4248

@@ -78,8 +84,19 @@ def filtered_lessons_scope
7884
end
7985

8086
def verify_school_class_belongs_to_school
81-
return if create_params[:school_class_id].blank?
82-
return if school&.classes&.pluck(:id)&.include?(create_params[:school_class_id])
87+
if params[:lesson_projects].present?
88+
params[:lesson_projects].each { |lesson_params| verify_lesson_school_class!(lesson_params) }
89+
else
90+
verify_lesson_school_class!(create_params)
91+
end
92+
end
93+
94+
def verify_lesson_school_class!(lesson_params)
95+
school_class_id = lesson_params[:school_class_id]
96+
return if school_class_id.blank?
97+
98+
school = School.find_by(id: lesson_params[:school_id])
99+
return if school&.classes&.pluck(:id)&.include?(school_class_id)
83100

84101
raise ParameterError, 'school_class_id does not correspond to school_id'
85102
end
@@ -118,6 +135,27 @@ def update_params
118135
)
119136
end
120137

138+
def bulk_create_params(lesson_project)
139+
lesson_project.permit(
140+
:school_id,
141+
:school_class_id,
142+
:name,
143+
:description,
144+
:visibility,
145+
:due_date,
146+
:origin_identifier,
147+
{
148+
project_attributes: [
149+
:name,
150+
:project_type,
151+
:locale,
152+
{ components: %i[id name extension content index default] },
153+
{ scratch_component: {} }
154+
]
155+
}
156+
).merge(user_id: current_user.id)
157+
end
158+
121159
def create_params
122160
params.fetch(:lesson, {}).permit(
123161
:school_id,
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# frozen_string_literal: true
2+
3+
json.array!(@results) do |result|
4+
if result.success?
5+
lesson, user = result[:lesson].with_user
6+
json.partial! 'lesson', lesson: lesson, user: user
7+
else
8+
json.error result[:error]
9+
end
10+
11+
json.origin_identifier result[:origin_identifier] if result[:origin_identifier].present?
12+
end
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# frozen_string_literal: true
2+
3+
class Lesson
4+
class CreateBulk
5+
class << self
6+
def call(lessons_params:)
7+
lessons_params.map { |lesson| create_one(lesson) }
8+
end
9+
10+
private
11+
12+
def create_one(lesson_params)
13+
origin_identifier = lesson_params[:origin_identifier]
14+
Lesson::Create.call(lesson_params: lesson_params.except(:origin_identifier)).tap do |result|
15+
result[:origin_identifier] = origin_identifier if origin_identifier.present?
16+
end
17+
end
18+
end
19+
end
20+
end
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# frozen_string_literal: true
2+
3+
require 'rails_helper'
4+
5+
RSpec.describe Lesson::CreateBulk, type: :unit do
6+
let(:school) { create(:school) }
7+
let(:teacher) { create(:teacher, school:) }
8+
9+
let(:lessons_params) do
10+
[
11+
{
12+
name: 'Test Lesson',
13+
user_id: teacher.id,
14+
school_id: school.id,
15+
origin_identifier: 'test-lesson-identifier-one',
16+
project_attributes: {
17+
name: 'Hello world project',
18+
project_type: Project::Types::PYTHON,
19+
components: [
20+
{ name: 'main.py', extension: 'py', content: 'print("Hello, world!")' }
21+
]
22+
}
23+
},
24+
{
25+
name: 'Test Lesson 2',
26+
user_id: teacher.id,
27+
school_id: school.id,
28+
origin_identifier: 'test-lesson-identifier-two',
29+
project_attributes: {
30+
name: 'Hello world project',
31+
project_type: Project::Types::PYTHON,
32+
components: [
33+
{ name: 'main.py', extension: 'py', content: 'print("Hello, world!")' }
34+
]
35+
}
36+
}
37+
]
38+
end
39+
40+
context 'with a teacher' do
41+
let(:result) { described_class.call(lessons_params:) }
42+
43+
before do
44+
allow(User).to receive(:from_userinfo).with(ids: teacher.id).and_return([teacher])
45+
end
46+
47+
it 'returns a successful operation response for the first lesson' do
48+
expect(result.first.success?).to be(true)
49+
end
50+
51+
it 'returns a successful operation response for the second lesson' do
52+
expect(result.second.success?).to be(true)
53+
end
54+
55+
it 'creates multiple lessons' do
56+
expect { described_class.call(lessons_params:) }.to change(Lesson, :count).by(2)
57+
end
58+
59+
it 'does not pass origin_identifier to lesson creation' do
60+
received_params = []
61+
allow(Lesson::Create).to receive(:call).and_wrap_original do |method, lesson_params:|
62+
received_params << lesson_params
63+
method.call(lesson_params:)
64+
end
65+
66+
described_class.call(lessons_params:)
67+
68+
expect(received_params).to all(satisfy { |params| !params.key?(:origin_identifier) })
69+
end
70+
71+
it 'appends the origin_identifier to the first created lesson' do
72+
expect(result.first[:origin_identifier]).to eq('test-lesson-identifier-one')
73+
end
74+
75+
it 'appends the origin_identifier to the second created lesson' do
76+
expect(result.second[:origin_identifier]).to eq('test-lesson-identifier-two')
77+
end
78+
end
79+
end

spec/features/lesson/creating_a_lesson_spec.rb

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,168 @@
118118
end
119119
end
120120

121+
context 'when bulk creating lessons via lesson_projects' do
122+
let(:lesson_project_params) do
123+
[
124+
{
125+
name: 'Lesson 1',
126+
school_id: school.id,
127+
project_attributes: { name: 'Project 1', project_type: Project::Types::CODE_EDITOR_SCRATCH }
128+
},
129+
{
130+
name: 'Lesson 2',
131+
school_id: school.id,
132+
project_attributes: { name: 'Project 2', project_type: Project::Types::CODE_EDITOR_SCRATCH }
133+
}
134+
]
135+
end
136+
137+
it 'responds 201 Created' do
138+
post('/api/lessons', headers:, params: { lesson_projects: lesson_project_params })
139+
expect(response).to have_http_status(:created)
140+
end
141+
142+
it 'creates one lesson per entry' do
143+
expect do
144+
post('/api/lessons', headers:, params: { lesson_projects: lesson_project_params })
145+
end.to change(Lesson, :count).by(2)
146+
end
147+
148+
it 'responds with the same lesson JSON shape as a single create' do
149+
post('/api/lessons', headers:, params: { lesson_projects: lesson_project_params })
150+
data = JSON.parse(response.body, symbolize_names: true)
151+
152+
expect(data).to all(include(:id, :name, :user_name))
153+
expect(data.pluck(:name)).to contain_exactly('Lesson 1', 'Lesson 2')
154+
end
155+
156+
it 'omits origin_identifier when not supplied' do
157+
post('/api/lessons', headers:, params: { lesson_projects: lesson_project_params })
158+
data = JSON.parse(response.body, symbolize_names: true)
159+
160+
expect(data).to all(satisfy { |entry| !entry.key?(:origin_identifier) })
161+
end
162+
163+
context 'when origin_identifier is supplied' do
164+
let(:lesson_project_params) do
165+
[
166+
{
167+
name: 'Lesson 1',
168+
school_id: school.id,
169+
origin_identifier: 'curriculum-project-one',
170+
project_attributes: { name: 'Project 1', project_type: Project::Types::CODE_EDITOR_SCRATCH }
171+
},
172+
{
173+
name: 'Lesson 2',
174+
school_id: school.id,
175+
origin_identifier: 'curriculum-project-two',
176+
project_attributes: { name: 'Project 2', project_type: Project::Types::CODE_EDITOR_SCRATCH }
177+
}
178+
]
179+
end
180+
181+
it 'echoes origin_identifier on each successful entry' do
182+
post('/api/lessons', headers:, params: { lesson_projects: lesson_project_params })
183+
data = JSON.parse(response.body, symbolize_names: true)
184+
expect(data.pluck(:origin_identifier)).to contain_exactly('curriculum-project-one', 'curriculum-project-two')
185+
end
186+
end
187+
188+
context 'when some entries are invalid' do
189+
let(:invalid_lesson_project_params) do
190+
lesson_project_params + [{
191+
name: ' ',
192+
school_id: school.id,
193+
origin_identifier: 'curriculum-project-three',
194+
project_attributes: { name: 'Project 3', project_type: Project::Types::CODE_EDITOR_SCRATCH }
195+
}]
196+
end
197+
198+
it 'responds 201 Created' do
199+
post('/api/lessons', headers:, params: { lesson_projects: invalid_lesson_project_params })
200+
expect(response).to have_http_status(:created)
201+
end
202+
203+
it 'includes an error entry for the failed lesson' do
204+
post('/api/lessons', headers:, params: { lesson_projects: invalid_lesson_project_params })
205+
expect(response.parsed_body.any? { |entry| entry['error'].present? }).to be true
206+
end
207+
208+
it 'still creates the valid lessons' do
209+
expect do
210+
post('/api/lessons', headers:, params: { lesson_projects: invalid_lesson_project_params })
211+
end.to change(Lesson, :count).by(2)
212+
end
213+
214+
it 'echoes origin_identifier on failed entries' do
215+
post('/api/lessons', headers:, params: { lesson_projects: invalid_lesson_project_params })
216+
error_entry = response.parsed_body.find { |entry| entry['error'].present? }
217+
218+
expect(error_entry['origin_identifier']).to eq('curriculum-project-three')
219+
end
220+
end
221+
222+
context 'when entries are associated with a school class' do
223+
let(:school_class) { create(:school_class, teacher_ids: [teacher.id], school:) }
224+
let(:lesson_project_params) do
225+
[
226+
{
227+
name: 'Lesson 1',
228+
school_id: school.id,
229+
school_class_id: school_class.id,
230+
project_attributes: { name: 'Project 1', project_type: Project::Types::CODE_EDITOR_SCRATCH }
231+
},
232+
{
233+
name: 'Lesson 2',
234+
school_id: school.id,
235+
school_class_id: school_class.id,
236+
project_attributes: { name: 'Project 2', project_type: Project::Types::CODE_EDITOR_SCRATCH }
237+
}
238+
]
239+
end
240+
241+
before do
242+
authenticated_in_hydra_as(teacher)
243+
school_class.update!(teachers: [ClassTeacher.new({ teacher_id: teacher.id })])
244+
end
245+
246+
it 'responds 201 Created' do
247+
post('/api/lessons', headers:, params: { lesson_projects: lesson_project_params })
248+
249+
expect(response).to have_http_status(:created)
250+
end
251+
252+
it 'responds 422 Unprocessable if school_class_id does not correspond to school_id' do
253+
mismatched_params = lesson_project_params.map { |entry| entry.merge(school_id: SecureRandom.uuid) }
254+
255+
post('/api/lessons', headers:, params: { lesson_projects: mismatched_params })
256+
257+
expect(response).to have_http_status(:unprocessable_content)
258+
end
259+
260+
it 'does not create any lessons when school_class_id does not correspond to school_id' do
261+
mismatched_params = lesson_project_params.map { |entry| entry.merge(school_id: SecureRandom.uuid) }
262+
263+
expect do
264+
post('/api/lessons', headers:, params: { lesson_projects: mismatched_params })
265+
end.not_to change(Lesson, :count)
266+
end
267+
268+
it 'rejects the request when only one entry has a mismatched school_id' do
269+
mismatched_params = [
270+
lesson_project_params.first,
271+
lesson_project_params.last.merge(school_id: SecureRandom.uuid)
272+
]
273+
274+
expect do
275+
post('/api/lessons', headers:, params: { lesson_projects: mismatched_params })
276+
end.not_to change(Lesson, :count)
277+
278+
expect(response).to have_http_status(:unprocessable_content)
279+
end
280+
end
281+
end
282+
121283
context 'when the lesson is associated with a school class' do
122284
let(:school_class) { create(:school_class, teacher_ids: [teacher.id], school:) }
123285
let(:school) { create(:school) }

0 commit comments

Comments
 (0)