Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions app/controllers/api/lessons/batch_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# frozen_string_literal: true

module Api
module Lessons
class BatchController < ApiController
include RemixSelection
include LessonCreation

before_action :authorize_user
before_action :verify_school_class_belongs_to_school
before_action :verify_can_create_scratch_projects
before_action :authorize_lesson_projects!

def create_batch
raise ParameterError, 'lesson_projects cannot be blank' unless lesson_projects?

@results = Lesson::CreateBatch.call(
lessons_params: batch_lessons_params
)
@user = current_user
render :create_batch, formats: [:json], status: :created
end

private

def verify_school_class_belongs_to_school
return unless lesson_projects?

params[:lesson_projects].each { |lesson_params| verify_lesson_school_class!(lesson_params) }
end

def verify_can_create_scratch_projects
return unless lesson_projects?

scratch_project_params = params[:lesson_projects].find { |lesson_params| scratch_project?(lesson_params) }
return unless scratch_project_params

verify_lesson_scratch!(scratch_project_params)
end

def batch_lessons_params
@batch_lessons_params ||= params[:lesson_projects].map { |lesson_params| create_batch_params(lesson_params) }
end

def create_batch_params(lesson_project)
lesson_project.permit(*LESSON_ATTRIBUTES, :origin_identifier, project_attributes: PROJECT_ATTRIBUTES).merge(user_id: current_user.id)
end

def lesson_projects?
projects = params[:lesson_projects]
return false unless projects.is_a?(Array)

projects.any?(&:present?)
end

def authorize_lesson_projects!
return unless lesson_projects?

batch_lessons_params.each do |lesson_params|
authorize! :create, Lesson.new(lesson_params.slice(:school_id, :school_class_id, :user_id))
end
end
end
end
end
34 changes: 5 additions & 29 deletions app/controllers/api/lessons_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
module Api
class LessonsController < ApiController

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This controller is getting longer now and I think having different code paths for the create action and many of the helpers does make it harder to follow and easier to introduce bugs (although I like having the attributes shared between them as it means that they are updated together).

Do you think it's worth improving this as part of this change? I wondered if it would be better or worse to have a different bulk controller, or if other behaviour could be moved outside of the controller.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've separated bulk creation into its own controller.

I've also created a LessonCreation concern for params and logic that are shared between single and batch lesson creation.

Let me know if LessonCreation makes it harder to follow the behaviour and I can move those methods back into the controllers.

include RemixSelection
include LessonCreation

before_action :authorize_user, except: %i[index show]
before_action :verify_school_class_belongs_to_school, only: :create
Expand Down Expand Up @@ -31,7 +32,6 @@ def show

def create
result = Lesson::Create.call(lesson_params: create_params)

if result.success?
@lesson_with_user = result[:lesson].with_user
render :show, formats: [:json], status: :created
Expand Down Expand Up @@ -78,16 +78,11 @@ def filtered_lessons_scope
end

def verify_school_class_belongs_to_school
return if create_params[:school_class_id].blank?
return if school&.classes&.pluck(:id)&.include?(create_params[:school_class_id])

raise ParameterError, 'school_class_id does not correspond to school_id'
verify_lesson_school_class!(create_params)
end

def verify_can_create_scratch_projects
return unless scratch_project? && !school.scratch_enabled?

render json: { error: 'Forbidden' }, status: :forbidden
verify_lesson_scratch!(create_params)
end

def user_remixes(lessons)
Expand All @@ -104,10 +99,6 @@ def user_remix(lesson)
)
end

def scratch_project?
create_params.dig(:project_attributes, :project_type) == Project::Types::CODE_EDITOR_SCRATCH
end

def update_params
params.fetch(:lesson, {}).permit(
:name,
Expand All @@ -119,23 +110,8 @@ def update_params
end

def create_params
params.fetch(:lesson, {}).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)
source = params.fetch(:lesson, {})
source.permit(*LESSON_ATTRIBUTES, project_attributes: PROJECT_ATTRIBUTES).merge(user_id: current_user.id)
end

def school_owner?
Expand Down
47 changes: 47 additions & 0 deletions app/controllers/concerns/lesson_creation.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# frozen_string_literal: true

module LessonCreation
extend ActiveSupport::Concern

LESSON_ATTRIBUTES = %i[
school_id
school_class_id
name
description
visibility
due_date
].freeze

PROJECT_ATTRIBUTES = [
:name,
:project_type,
:locale,
{ components: %i[id name extension content index default] },
{ scratch_component: {} }
].freeze

private

def verify_lesson_school_class!(lesson_params)
school_class_id = lesson_params[:school_class_id]
return if school_class_id.blank?

school = School.find_by(id: lesson_params[:school_id])
return if school&.classes&.exists?(id: school_class_id)

raise ParameterError, 'school_class_id does not correspond to school_id'
end

def verify_lesson_scratch!(lesson_params)
return unless scratch_project?(lesson_params)

school = School.find_by(id: lesson_params[:school_id])
return if school&.scratch_enabled?

render json: { error: 'Forbidden' }, status: :forbidden
end

def scratch_project?(lesson_params)
lesson_params.dig(:project_attributes, :project_type) == Project::Types::CODE_EDITOR_SCRATCH
end
end
11 changes: 11 additions & 0 deletions app/views/api/lessons/batch/create_batch.json.jbuilder
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# frozen_string_literal: true

json.array!(@results) do |result|
if result.success?
json.partial! 'api/lessons/lesson', lesson: result[:lesson], user: @user
else
json.error result[:error]
end

json.origin_identifier result[:origin_identifier] if result[:origin_identifier].present?
end
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@

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

resources :teacher_invitations, param: :token, only: :show do
Expand Down
20 changes: 20 additions & 0 deletions lib/concepts/lesson/operations/create_batch.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# frozen_string_literal: true

class Lesson
class CreateBatch
class << self
def call(lessons_params:)
lessons_params.map { |lesson| create_one(lesson) }
end

private

def create_one(lesson_params)
origin_identifier = lesson_params[:origin_identifier]
Lesson::Create.call(lesson_params: lesson_params.except(:origin_identifier)).tap do |result|
result[:origin_identifier] = origin_identifier if origin_identifier.present?
end
end
end
end
end
79 changes: 79 additions & 0 deletions spec/concepts/lesson/create_batch_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe Lesson::CreateBatch, type: :unit do
let(:school) { create(:school) }
let(:teacher) { create(:teacher, school:) }

let(:lessons_params) do
[
{
name: 'Test Lesson',
user_id: teacher.id,
school_id: school.id,
origin_identifier: 'test-lesson-identifier-one',
project_attributes: {
name: 'Hello world project',
project_type: Project::Types::PYTHON,
components: [
{ name: 'main.py', extension: 'py', content: 'print("Hello, world!")' }
]
}
},
{
name: 'Test Lesson 2',
user_id: teacher.id,
school_id: school.id,
origin_identifier: 'test-lesson-identifier-two',
project_attributes: {
name: 'Hello world project',
project_type: Project::Types::PYTHON,
components: [
{ name: 'main.py', extension: 'py', content: 'print("Hello, world!")' }
]
}
}
]
end

context 'with a teacher' do
let(:result) { described_class.call(lessons_params:) }

before do
allow(User).to receive(:from_userinfo).with(ids: teacher.id).and_return([teacher])
end

it 'returns a successful operation response for the first lesson' do
expect(result.first.success?).to be(true)
end

it 'returns a successful operation response for the second lesson' do
expect(result.second.success?).to be(true)
end

it 'creates multiple lessons' do
expect { described_class.call(lessons_params:) }.to change(Lesson, :count).by(2)
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

described_class.call(lessons_params:)

expect(received_params).to all(satisfy { |params| !params.key?(:origin_identifier) })
end

it 'appends the origin_identifier to the first created lesson' do
expect(result.first[:origin_identifier]).to eq('test-lesson-identifier-one')
end

it 'appends the origin_identifier to the second created lesson' do
expect(result.second[:origin_identifier]).to eq('test-lesson-identifier-two')
end
end
end
Loading
Loading