Skip to content

Commit f846fa5

Browse files
Bulk lesson creation (#853)
## Status - Closes [#1472](RaspberryPiFoundation/digital-editor-issues#1472 (comment)) - Related to [Experience CS #1761 ](RaspberryPiFoundation/experience-cs#1761) ## What's changed? - Add Lessons::Batch controller - Add a bulk create jbuilder that renders an array, echoing an `origin_identifier` when supplied so the caller can match responses to submitted projects - Add a LessonsCreation concern to consolidate logic and params that are share between batch and single lesson creation - Update specs
1 parent 8ae7cab commit f846fa5

8 files changed

Lines changed: 461 additions & 29 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# frozen_string_literal: true
2+
3+
module Api
4+
module Lessons
5+
class BatchController < ApiController
6+
include RemixSelection
7+
include LessonCreation
8+
9+
before_action :authorize_user
10+
before_action :verify_school_class_belongs_to_school
11+
before_action :verify_can_create_scratch_projects
12+
before_action :authorize_lesson_projects!
13+
14+
def create_batch
15+
raise ParameterError, 'lesson_projects cannot be blank' unless lesson_projects?
16+
17+
@results = Lesson::CreateBatch.call(
18+
lessons_params: batch_lessons_params
19+
)
20+
@user = current_user
21+
render :create_batch, formats: [:json], status: :created
22+
end
23+
24+
private
25+
26+
def verify_school_class_belongs_to_school
27+
return unless lesson_projects?
28+
29+
params[:lesson_projects].each { |lesson_params| verify_lesson_school_class!(lesson_params) }
30+
end
31+
32+
def verify_can_create_scratch_projects
33+
return unless lesson_projects?
34+
35+
scratch_project_params = params[:lesson_projects].find { |lesson_params| scratch_project?(lesson_params) }
36+
return unless scratch_project_params
37+
38+
verify_lesson_scratch!(scratch_project_params)
39+
end
40+
41+
def batch_lessons_params
42+
@batch_lessons_params ||= params[:lesson_projects].map { |lesson_params| create_batch_params(lesson_params) }
43+
end
44+
45+
def create_batch_params(lesson_project)
46+
lesson_project.permit(*LESSON_ATTRIBUTES, :origin_identifier, project_attributes: PROJECT_ATTRIBUTES).merge(user_id: current_user.id)
47+
end
48+
49+
def lesson_projects?
50+
projects = params[:lesson_projects]
51+
return false unless projects.is_a?(Array)
52+
53+
projects.any?(&:present?)
54+
end
55+
56+
def authorize_lesson_projects!
57+
return unless lesson_projects?
58+
59+
batch_lessons_params.each do |lesson_params|
60+
authorize! :create, Lesson.new(lesson_params.slice(:school_id, :school_class_id, :user_id))
61+
end
62+
end
63+
end
64+
end
65+
end

app/controllers/api/lessons_controller.rb

Lines changed: 5 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
module Api
44
class LessonsController < ApiController
55
include RemixSelection
6+
include LessonCreation
67

78
before_action :authorize_user, except: %i[index show]
89
before_action :verify_school_class_belongs_to_school, only: :create
@@ -31,7 +32,6 @@ def show
3132

3233
def create
3334
result = Lesson::Create.call(lesson_params: create_params)
34-
3535
if result.success?
3636
@lesson_with_user = result[:lesson].with_user
3737
render :show, formats: [:json], status: :created
@@ -78,16 +78,11 @@ def filtered_lessons_scope
7878
end
7979

8080
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])
83-
84-
raise ParameterError, 'school_class_id does not correspond to school_id'
81+
verify_lesson_school_class!(create_params)
8582
end
8683

8784
def verify_can_create_scratch_projects
88-
return unless scratch_project? && !school.scratch_enabled?
89-
90-
render json: { error: 'Forbidden' }, status: :forbidden
85+
verify_lesson_scratch!(create_params)
9186
end
9287

9388
def user_remixes(lessons)
@@ -104,10 +99,6 @@ def user_remix(lesson)
10499
)
105100
end
106101

107-
def scratch_project?
108-
create_params.dig(:project_attributes, :project_type) == Project::Types::CODE_EDITOR_SCRATCH
109-
end
110-
111102
def update_params
112103
params.fetch(:lesson, {}).permit(
113104
:name,
@@ -119,23 +110,8 @@ def update_params
119110
end
120111

121112
def create_params
122-
params.fetch(:lesson, {}).permit(
123-
:school_id,
124-
:school_class_id,
125-
:name,
126-
:description,
127-
:visibility,
128-
:due_date,
129-
{
130-
project_attributes: [
131-
:name,
132-
:project_type,
133-
:locale,
134-
{ components: %i[id name extension content index default] },
135-
{ scratch_component: {} }
136-
]
137-
}
138-
).merge(user_id: current_user.id)
113+
source = params.fetch(:lesson, {})
114+
source.permit(*LESSON_ATTRIBUTES, project_attributes: PROJECT_ATTRIBUTES).merge(user_id: current_user.id)
139115
end
140116

141117
def school_owner?
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# frozen_string_literal: true
2+
3+
module LessonCreation
4+
extend ActiveSupport::Concern
5+
6+
LESSON_ATTRIBUTES = %i[
7+
school_id
8+
school_class_id
9+
name
10+
description
11+
visibility
12+
due_date
13+
].freeze
14+
15+
PROJECT_ATTRIBUTES = [
16+
:name,
17+
:project_type,
18+
:locale,
19+
{ components: %i[id name extension content index default] },
20+
{ scratch_component: {} }
21+
].freeze
22+
23+
private
24+
25+
def verify_lesson_school_class!(lesson_params)
26+
school_class_id = lesson_params[:school_class_id]
27+
return if school_class_id.blank?
28+
29+
school = School.find_by(id: lesson_params[:school_id])
30+
return if school&.classes&.exists?(id: school_class_id)
31+
32+
raise ParameterError, 'school_class_id does not correspond to school_id'
33+
end
34+
35+
def verify_lesson_scratch!(lesson_params)
36+
return unless scratch_project?(lesson_params)
37+
38+
school = School.find_by(id: lesson_params[:school_id])
39+
return if school&.scratch_enabled?
40+
41+
render json: { error: 'Forbidden' }, status: :forbidden
42+
end
43+
44+
def scratch_project?(lesson_params)
45+
lesson_params.dig(:project_attributes, :project_type) == Project::Types::CODE_EDITOR_SCRATCH
46+
end
47+
end
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! 'api/lessons/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

config/routes.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686

8787
resources :lessons, only: %i[index create show update destroy] do
8888
post :copy, on: :member, to: 'lessons#create_copy'
89+
post :batch, on: :collection, to: 'lessons/batch#create_batch'
8990
end
9091

9192
resources :teacher_invitations, param: :token, only: :show do
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 CreateBatch
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::CreateBatch, 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

0 commit comments

Comments
 (0)