Skip to content

Commit 7ec0b21

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 d914ebd commit 7ec0b21

5 files changed

Lines changed: 338 additions & 11 deletions

File tree

app/controllers/api/lessons_controller.rb

Lines changed: 64 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,20 @@ 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+
@user = current_user
38+
render :create_bulk, formats: [:json], status: :created
3839
else
39-
render json: { error: result[:error] }, status: :unprocessable_content
40+
result = Lesson::Create.call(lesson_params: create_params)
41+
if result.success?
42+
@lesson_with_user = result[:lesson].with_user
43+
render :show, formats: [:json], status: :created
44+
else
45+
render json: { error: result[:error] }, status: :unprocessable_content
46+
end
4047
end
4148
end
4249

@@ -78,14 +85,39 @@ def filtered_lessons_scope
7885
end
7986

8087
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])
88+
if params[:lesson_projects].present?
89+
params[:lesson_projects].each { |lesson_params| verify_lesson_school_class!(lesson_params) }
90+
else
91+
verify_lesson_school_class!(create_params)
92+
end
93+
end
94+
95+
def verify_lesson_school_class!(lesson_params)
96+
school_class_id = lesson_params[:school_class_id]
97+
return if school_class_id.blank?
98+
99+
school = School.find_by(id: lesson_params[:school_id])
100+
return if school&.classes&.exists?(id: school_class_id)
83101

84102
raise ParameterError, 'school_class_id does not correspond to school_id'
85103
end
86104

87105
def verify_can_create_scratch_projects
88-
return unless scratch_project? && !school.scratch_enabled?
106+
if params[:lesson_projects].present?
107+
scratch_project_params = params[:lesson_projects].find { |lesson_params| scratch_project?(lesson_params) }
108+
return unless scratch_project_params
109+
110+
verify_lesson_scratch!(scratch_project_params)
111+
else
112+
verify_lesson_scratch!(create_params)
113+
end
114+
end
115+
116+
def verify_lesson_scratch!(lesson_params)
117+
return unless scratch_project?(lesson_params)
118+
119+
school = School.find_by(id: lesson_params[:school_id])
120+
return if school&.scratch_enabled?
89121

90122
render json: { error: 'Forbidden' }, status: :forbidden
91123
end
@@ -104,8 +136,8 @@ def user_remix(lesson)
104136
)
105137
end
106138

107-
def scratch_project?
108-
create_params.dig(:project_attributes, :project_type) == Project::Types::CODE_EDITOR_SCRATCH
139+
def scratch_project?(lesson_params)
140+
lesson_params.dig(:project_attributes, :project_type) == Project::Types::CODE_EDITOR_SCRATCH
109141
end
110142

111143
def update_params
@@ -118,6 +150,27 @@ def update_params
118150
)
119151
end
120152

153+
def bulk_create_params(lesson_project)
154+
lesson_project.permit(
155+
:school_id,
156+
:school_class_id,
157+
:name,
158+
:description,
159+
:visibility,
160+
:due_date,
161+
:origin_identifier,
162+
{
163+
project_attributes: [
164+
:name,
165+
:project_type,
166+
:locale,
167+
{ components: %i[id name extension content index default] },
168+
{ scratch_component: {} }
169+
]
170+
}
171+
).merge(user_id: current_user.id)
172+
end
173+
121174
def create_params
122175
params.fetch(:lesson, {}).permit(
123176
:school_id,
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# frozen_string_literal: true
2+
3+
json.array!(@results) do |result|
4+
if result.success?
5+
json.partial! 'lesson', lesson: result[:lesson], user: @user
6+
else
7+
json.error result[:error]
8+
end
9+
10+
json.origin_identifier result[:origin_identifier] if result[:origin_identifier].present?
11+
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: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,170 @@
118118
end
119119
end
120120

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

0 commit comments

Comments
 (0)