From a26b6ea194bccf51d447fc954f5827de6b24171a Mon Sep 17 00:00:00 2001 From: Pete Simonovic <69108995+PetarSimonovic@users.noreply.github.com> Date: Tue, 12 May 2026 09:14:58 +0100 Subject: [PATCH 1/3] Implement bulk lesson creation Expand the LessonController's create method to accept lesson_projects within a request body and bulk create records --- app/controllers/api/lessons_controller.rb | 39 ++++++++++++--- .../features/lesson/creating_a_lesson_spec.rb | 50 +++++++++++++++++++ 2 files changed, 82 insertions(+), 7 deletions(-) diff --git a/app/controllers/api/lessons_controller.rb b/app/controllers/api/lessons_controller.rb index ebcf702db..7748f5e6d 100644 --- a/app/controllers/api/lessons_controller.rb +++ b/app/controllers/api/lessons_controller.rb @@ -29,15 +29,20 @@ def show end def create - result = Lesson::Create.call(lesson_params:) - - if result.success? - @lesson_with_user = result[:lesson].with_user - render :show, formats: [:json], status: :created + if params[:lesson_projects].present? + results = params[:lesson_projects].map { |lp| Lesson::Create.call(lesson_params: bulk_lesson_params(lp)) } + render json: results.map { |r| r.success? ? { lesson: r[:lesson].with_user } : { error: r[:error] } }, + status: :created else - render json: { error: result[:error] }, status: :unprocessable_content + result = Lesson::Create.call(lesson_params:) + if result.success? + @lesson_with_user = result[:lesson].with_user + render :show, formats: [:json], status: :created + else + render json: { error: result[:error] }, status: :unprocessable_content + end end - end + end def create_copy result = Lesson::CreateCopy.call(lesson: @lesson, lesson_params:) @@ -101,6 +106,26 @@ def lesson_params base_params.merge(user_id: current_user.id) end + def bulk_lesson_params(lesson_project) + lesson_project.permit( + :school_id, + :school_class_id, + :name, + :description, + :visibility, + :due_date, + { + project_attributes: [ + :name, + :project_type, + :locale, + { components: %i[id name extension content index default] }, + { scratch_component: {} } + ] + } + ).merge(user_id: current_user.id) + end + def base_params params.fetch(:lesson, {}).permit( :school_id, diff --git a/spec/features/lesson/creating_a_lesson_spec.rb b/spec/features/lesson/creating_a_lesson_spec.rb index 33d80c73e..b2a08a807 100644 --- a/spec/features/lesson/creating_a_lesson_spec.rb +++ b/spec/features/lesson/creating_a_lesson_spec.rb @@ -117,6 +117,56 @@ end end + context 'when bulk creating lessons via lesson_projects' do + let(:lesson_project_params) do + [ + { + name: 'Lesson 1', + school_id: school.id, + project_attributes: { name: 'Project 1', project_type: Project::Types::CODE_EDITOR_SCRATCH } + }, + { + name: 'Lesson 2', + school_id: school.id, + project_attributes: { name: 'Project 2', project_type: Project::Types::CODE_EDITOR_SCRATCH } + } + ] + end + + it 'responds 201 Created' do + post('/api/lessons', headers:, params: { lesson_projects: lesson_project_params }) + expect(response).to have_http_status(:created) + end + + it 'creates one lesson per entry' do + expect { + post('/api/lessons', headers:, params: { lesson_projects: lesson_project_params }) + }.to change(Lesson, :count).by(2) + end + + context 'when some entries are invalid' do + let(:invalid_lesson_project_params) do + lesson_project_params + [{ name: ' ' }] + end + + it 'responds 201 Created' do + post('/api/lessons', headers:, params: { lesson_projects: invalid_lesson_project_params }) + expect(response).to have_http_status(:created) + end + + it 'includes an error entry for the failed lesson' do + post('/api/lessons', headers:, params: { lesson_projects: invalid_lesson_project_params }) + expect(response.parsed_body.any? { |entry| entry['error'].present? }).to be true + end + + it 'still creates the valid lessons' do + expect { + post('/api/lessons', headers:, params: { lesson_projects: invalid_lesson_project_params }) + }.to change(Lesson, :count).by(2) + end + end + end + context 'when the lesson is associated with a school class' do let(:school_class) { create(:school_class, teacher_ids: [teacher.id], school:) } let(:school) { create(:school) } From f4a43717fd2ee85afcc6cbae5d0c7265b42b8375 Mon Sep 17 00:00:00 2001 From: Pete Simonovic <69108995+PetarSimonovic@users.noreply.github.com> Date: Mon, 1 Jun 2026 10:09:35 +0100 Subject: [PATCH 2/3] Bulk creation --- app/controllers/api/lessons_controller.rb | 7 ++++--- app/views/api/lessons/bulk_create.json.jbuilder | 10 ++++++++++ lib/concepts/lesson/operations/create.rb | 8 +++++++- spec/features/lesson/creating_a_lesson_spec.rb | 8 ++++++++ 4 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 app/views/api/lessons/bulk_create.json.jbuilder diff --git a/app/controllers/api/lessons_controller.rb b/app/controllers/api/lessons_controller.rb index 7748f5e6d..79503a40a 100644 --- a/app/controllers/api/lessons_controller.rb +++ b/app/controllers/api/lessons_controller.rb @@ -8,6 +8,7 @@ class LessonsController < ApiController before_action :verify_school_class_belongs_to_school, only: :create load_and_authorize_resource :lesson + def index accessible_lessons = filtered_lessons_scope .accessible_by(current_ability) @@ -30,9 +31,8 @@ def show def create if params[:lesson_projects].present? - results = params[:lesson_projects].map { |lp| Lesson::Create.call(lesson_params: bulk_lesson_params(lp)) } - render json: results.map { |r| r.success? ? { lesson: r[:lesson].with_user } : { error: r[:error] } }, - status: :created + @results = params[:lesson_projects].map { |lp| Lesson::Create.call(lesson_params: bulk_lesson_params(lp)) } + render :bulk_create, formats: [:json], status: :created else result = Lesson::Create.call(lesson_params:) if result.success? @@ -114,6 +114,7 @@ def bulk_lesson_params(lesson_project) :description, :visibility, :due_date, + # add project identifier { project_attributes: [ :name, diff --git a/app/views/api/lessons/bulk_create.json.jbuilder b/app/views/api/lessons/bulk_create.json.jbuilder new file mode 100644 index 000000000..141d6994a --- /dev/null +++ b/app/views/api/lessons/bulk_create.json.jbuilder @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +json.array!(@results) do |result| + if result.success? + lesson, user = result[:lesson].with_user + json.partial! 'lesson', lesson: lesson, user: user + else + json.error result[:error] + end +end diff --git a/lib/concepts/lesson/operations/create.rb b/lib/concepts/lesson/operations/create.rb index 77ac1d55d..5d9ff30ae 100644 --- a/lib/concepts/lesson/operations/create.rb +++ b/lib/concepts/lesson/operations/create.rb @@ -7,6 +7,8 @@ def call(lesson_params:) response = OperationResponse.new response[:lesson] = build_lesson(lesson_params) response[:lesson].save! + puts("RESPONSE!!!!!") + puts(response) response rescue StandardError => e Sentry.capture_exception(e) @@ -21,12 +23,16 @@ def call(lesson_params:) private + # Instead of calling Project.new for bulk creation, find the Project and take its attributes def build_lesson(lesson_hash) + puts("BUILDING!!") new_lesson = Lesson.new(lesson_hash.except(:project_attributes)) project_params = lesson_hash[:project_attributes].merge({ user_id: lesson_hash[:user_id], school_id: lesson_hash[:school_id], lesson_id: new_lesson.id }) - new_lesson.project = Project.new(project_params) + new_lesson.project = Project.new(project_params) + puts("NEW LESSON!!") + pp(new_lesson.project) new_lesson end end diff --git a/spec/features/lesson/creating_a_lesson_spec.rb b/spec/features/lesson/creating_a_lesson_spec.rb index b2a08a807..e629bc61f 100644 --- a/spec/features/lesson/creating_a_lesson_spec.rb +++ b/spec/features/lesson/creating_a_lesson_spec.rb @@ -144,6 +144,14 @@ }.to change(Lesson, :count).by(2) end + it 'responds with the same lesson JSON shape as a single create' do + post('/api/lessons', headers:, params: { lesson_projects: lesson_project_params }) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data).to all(include(:id, :name, :user_name)) + expect(data.pluck(:name)).to contain_exactly('Lesson 1', 'Lesson 2') + end + context 'when some entries are invalid' do let(:invalid_lesson_project_params) do lesson_project_params + [{ name: ' ' }] From e6fbf5efcd258c900c91a69b9c3dc8394579b0b7 Mon Sep 17 00:00:00 2001 From: Pete Simonovic <69108995+PetarSimonovic@users.noreply.github.com> Date: Fri, 5 Jun 2026 12:37:18 +0100 Subject: [PATCH 3/3] Lessons controller returns origin_identifier in response to bulk create --- app/controllers/api/lessons_controller.rb | 12 +++- .../api/lessons/bulk_create.json.jbuilder | 2 + lib/concepts/lesson/operations/create.rb | 6 -- .../features/lesson/creating_a_lesson_spec.rb | 61 ++++++++++++++++++- 4 files changed, 71 insertions(+), 10 deletions(-) diff --git a/app/controllers/api/lessons_controller.rb b/app/controllers/api/lessons_controller.rb index 79503a40a..851f0c1af 100644 --- a/app/controllers/api/lessons_controller.rb +++ b/app/controllers/api/lessons_controller.rb @@ -31,7 +31,7 @@ def show def create if params[:lesson_projects].present? - @results = params[:lesson_projects].map { |lp| Lesson::Create.call(lesson_params: bulk_lesson_params(lp)) } + @results = params[:lesson_projects].map { |lp| create_lesson_from_lesson_project(lp) } render :bulk_create, formats: [:json], status: :created else result = Lesson::Create.call(lesson_params:) @@ -106,6 +106,14 @@ def lesson_params base_params.merge(user_id: current_user.id) end + def create_lesson_from_lesson_project(lesson_project) + permitted = bulk_lesson_params(lesson_project) + origin_identifier = permitted[:origin_identifier] + result = Lesson::Create.call(lesson_params: permitted.except(:origin_identifier)) + result[:origin_identifier] = origin_identifier + result + end + def bulk_lesson_params(lesson_project) lesson_project.permit( :school_id, @@ -114,7 +122,7 @@ def bulk_lesson_params(lesson_project) :description, :visibility, :due_date, - # add project identifier + :origin_identifier, # echoed in bulk response only; lets the client match each result to its request { project_attributes: [ :name, diff --git a/app/views/api/lessons/bulk_create.json.jbuilder b/app/views/api/lessons/bulk_create.json.jbuilder index 141d6994a..7cb74ecb6 100644 --- a/app/views/api/lessons/bulk_create.json.jbuilder +++ b/app/views/api/lessons/bulk_create.json.jbuilder @@ -7,4 +7,6 @@ json.array!(@results) do |result| else json.error result[:error] end + + json.origin_identifier result[:origin_identifier] if result[:origin_identifier].present? end diff --git a/lib/concepts/lesson/operations/create.rb b/lib/concepts/lesson/operations/create.rb index 5d9ff30ae..f57011703 100644 --- a/lib/concepts/lesson/operations/create.rb +++ b/lib/concepts/lesson/operations/create.rb @@ -7,8 +7,6 @@ def call(lesson_params:) response = OperationResponse.new response[:lesson] = build_lesson(lesson_params) response[:lesson].save! - puts("RESPONSE!!!!!") - puts(response) response rescue StandardError => e Sentry.capture_exception(e) @@ -23,16 +21,12 @@ def call(lesson_params:) private - # Instead of calling Project.new for bulk creation, find the Project and take its attributes def build_lesson(lesson_hash) - puts("BUILDING!!") new_lesson = Lesson.new(lesson_hash.except(:project_attributes)) project_params = lesson_hash[:project_attributes].merge({ user_id: lesson_hash[:user_id], school_id: lesson_hash[:school_id], lesson_id: new_lesson.id }) new_lesson.project = Project.new(project_params) - puts("NEW LESSON!!") - pp(new_lesson.project) new_lesson end end diff --git a/spec/features/lesson/creating_a_lesson_spec.rb b/spec/features/lesson/creating_a_lesson_spec.rb index e629bc61f..6807aaf51 100644 --- a/spec/features/lesson/creating_a_lesson_spec.rb +++ b/spec/features/lesson/creating_a_lesson_spec.rb @@ -152,11 +152,61 @@ expect(data.pluck(:name)).to contain_exactly('Lesson 1', 'Lesson 2') end + it 'omits origin_identifier when not supplied' do + post('/api/lessons', headers:, params: { lesson_projects: lesson_project_params }) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data).to all(satisfy { |entry| !entry.key?(:origin_identifier) }) + end + + context 'when origin_identifier is supplied' do + let(:lesson_project_params) do + [ + { + name: 'Lesson 1', + school_id: school.id, + origin_identifier: 'curriculum-project-one', + project_attributes: { name: 'Project 1', project_type: Project::Types::CODE_EDITOR_SCRATCH } + }, + { + name: 'Lesson 2', + school_id: school.id, + origin_identifier: 'curriculum-project-two', + project_attributes: { name: 'Project 2', project_type: Project::Types::CODE_EDITOR_SCRATCH } + } + ] + end + + it 'echoes origin_identifier on each successful entry' do + post('/api/lessons', headers:, params: { lesson_projects: lesson_project_params }) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.pluck(:origin_identifier)).to contain_exactly('curriculum-project-one', 'curriculum-project-two') + end + + it 'does not pass origin_identifier to lesson creation' do + received_params = [] + allow(Lesson::Create).to receive(:call).and_wrap_original do |method, lesson_params:| + received_params << lesson_params + method.call(lesson_params:) + end + + post('/api/lessons', headers:, params: { lesson_projects: lesson_project_params }) + + expect(received_params).to all(satisfy { |params| !params.key?(:origin_identifier) }) + end + end + context 'when some entries are invalid' do let(:invalid_lesson_project_params) do - lesson_project_params + [{ name: ' ' }] + lesson_project_params + [{ + name: ' ', + school_id: school.id, + origin_identifier: 'curriculum-project-three', + project_attributes: { name: 'Project 3', project_type: Project::Types::CODE_EDITOR_SCRATCH } + }] end - + it 'responds 201 Created' do post('/api/lessons', headers:, params: { lesson_projects: invalid_lesson_project_params }) expect(response).to have_http_status(:created) @@ -172,6 +222,13 @@ post('/api/lessons', headers:, params: { lesson_projects: invalid_lesson_project_params }) }.to change(Lesson, :count).by(2) end + + it 'echoes origin_identifier on failed entries' do + post('/api/lessons', headers:, params: { lesson_projects: invalid_lesson_project_params }) + error_entry = response.parsed_body.find { |entry| entry['error'].present? } + + expect(error_entry['origin_identifier']).to eq('curriculum-project-three') + end end end