Skip to content

Commit 82faeab

Browse files
authored
Let owners toggle Scratch for their school (#839)
## Status - Related to RaspberryPiFoundation/digital-editor-issues#1375 ## What's changed? Previously members of schools could access Scratch projects if they had the `cat_mode` feature was enabled. This change allows members of schools to view/remix/update existing scratch projects they have access to, regardless of the feature flag. There is a new `scratch_enabled` flag in the database that decides if school members can create new scratch projects. As part of this we have: - Re-purposed the existing update school endpoint to allow for updating the new scratch_enabled flag - Changed the logic in the scratch base controller to just check for school membership instead of the feature - Checked the `scratch_enabled` flag in the lessons controller - Made the lessons controller safer by restricting what params are accepted in updates - Removed duplicated asset tests - Made sure the Scratch controllers authorizes resources using our standard pattern ## As part of deploy We should deploy the frontend changes soon after this to make it easier to toggle the Scratch feature
1 parent 582bcb0 commit 82faeab

17 files changed

Lines changed: 191 additions & 141 deletions

app/controllers/api/lessons_controller.rb

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ class LessonsController < ApiController
66

77
before_action :authorize_user, except: %i[index show]
88
before_action :verify_school_class_belongs_to_school, only: :create
9+
before_action :verify_can_create_scratch_projects, only: %i[create create_copy]
910
load_and_authorize_resource :lesson
1011

1112
def index
@@ -29,7 +30,7 @@ def show
2930
end
3031

3132
def create
32-
result = Lesson::Create.call(lesson_params:)
33+
result = Lesson::Create.call(lesson_params: create_params)
3334

3435
if result.success?
3536
@lesson_with_user = result[:lesson].with_user
@@ -40,7 +41,7 @@ def create
4041
end
4142

4243
def create_copy
43-
result = Lesson::CreateCopy.call(lesson: @lesson, lesson_params:)
44+
result = Lesson::CreateCopy.call(lesson: @lesson, lesson_params: create_params)
4445

4546
if result.success?
4647
@lesson_with_user = result[:lesson].with_user
@@ -53,7 +54,7 @@ def create_copy
5354
def update
5455
# TODO: Consider removing user_id from the lesson_params for update so users can update other users' lessons without changing ownership
5556
# OR consider dropping user_id on lessons and using teacher id/ids on the class instead
56-
result = Lesson::Update.call(lesson: @lesson, lesson_params:)
57+
result = Lesson::Update.call(lesson: @lesson, lesson_params: update_params)
5758

5859
if result.success?
5960
@lesson_with_user = result[:lesson].with_user
@@ -77,12 +78,18 @@ def filtered_lessons_scope
7778
end
7879

7980
def verify_school_class_belongs_to_school
80-
return if base_params[:school_class_id].blank?
81-
return if school&.classes&.pluck(:id)&.include?(base_params[:school_class_id])
81+
return if create_params[:school_class_id].blank?
82+
return if school&.classes&.pluck(:id)&.include?(create_params[:school_class_id])
8283

8384
raise ParameterError, 'school_class_id does not correspond to school_id'
8485
end
8586

87+
def verify_can_create_scratch_projects
88+
return unless scratch_project? && !school.scratch_enabled?
89+
90+
render json: { error: 'Forbidden' }, status: :forbidden
91+
end
92+
8693
def user_remixes(lessons)
8794
lessons.map { |lesson| user_remix(lesson) }
8895
end
@@ -97,11 +104,21 @@ def user_remix(lesson)
97104
)
98105
end
99106

100-
def lesson_params
101-
base_params.merge(user_id: current_user.id)
107+
def scratch_project?
108+
create_params.dig(:project_attributes, :project_type) == Project::Types::CODE_EDITOR_SCRATCH
109+
end
110+
111+
def update_params
112+
params.fetch(:lesson, {}).permit(
113+
:name,
114+
:visibility,
115+
{
116+
project_attributes: [:name]
117+
}
118+
)
102119
end
103120

104-
def base_params
121+
def create_params
105122
params.fetch(:lesson, {}).permit(
106123
:school_id,
107124
:school_class_id,
@@ -118,15 +135,15 @@ def base_params
118135
{ scratch_component: {} }
119136
]
120137
}
121-
)
138+
).merge(user_id: current_user.id)
122139
end
123140

124141
def school_owner?
125142
school && current_user.school_owner?(school)
126143
end
127144

128145
def school
129-
@school ||= @lesson&.school || School.find_by(id: base_params[:school_id]) || SchoolClass.find_by(id: params[:school_class_id])&.school
146+
@school ||= @lesson&.school || School.find_by(id: create_params[:school_id]) || SchoolClass.find_by(id: params[:school_class_id])&.school
130147
end
131148
end
132149
end

app/controllers/api/schools_controller.rb

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def show
1616
end
1717

1818
def create
19-
result = School::Create.call(school_params:, creator_id: current_user.id, token: current_user.token)
19+
result = School::Create.call(school_params: create_params, creator_id: current_user.id, token: current_user.token)
2020

2121
if result.success?
2222
@school = result[:school]
@@ -31,7 +31,7 @@ def create
3131

3232
def update
3333
school = School.find(params[:id])
34-
result = School::Update.call(school:, school_params:)
34+
result = School::Update.call(school:, school_params: update_params)
3535

3636
if result.success?
3737
@school = result[:school]
@@ -76,7 +76,7 @@ def import
7676

7777
private
7878

79-
def school_params
79+
def create_params
8080
params.expect(
8181
school: %i[name
8282
website
@@ -99,5 +99,11 @@ def school_params
9999
user_origin]
100100
)
101101
end
102+
103+
def update_params
104+
params.expect(
105+
school: %i[scratch_enabled]
106+
)
107+
end
102108
end
103109
end

app/controllers/api/scratch/assets_controller.rb

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,11 @@ module Scratch
55
class AssetsController < ScratchController
66
include ActiveStorage::SetCurrent
77

8-
skip_before_action :authorize_user, only: [:show]
98
prepend_before_action :load_project_from_header, only: %i[show create]
9+
authorize_resource :project_from_header
1010

1111
def show
1212
filename_with_extension = "#{params[:id]}.#{params[:format]}"
13-
authorize! :show, @project_from_header
1413

1514
scratch_asset = ScratchAsset.find_visible_to_project(
1615
project: @project_from_header,
@@ -23,8 +22,6 @@ def show
2322
end
2423

2524
def create
26-
authorize! :show, @project_from_header
27-
2825
filename_with_extension = "#{params[:id]}.#{params[:format]}"
2926
scratch_asset = ScratchAsset.find_or_initialize_by(
3027
project: @project_from_header,

app/controllers/api/scratch/projects_controller.rb

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,19 @@ module Scratch
55
class ProjectsController < ScratchController
66
include RemixSelection
77

8-
skip_before_action :authorize_user, only: [:show]
9-
skip_before_action :check_scratch_feature, only: [:show]
10-
before_action :load_project, only: %i[show update]
8+
before_action :load_project, except: %i[create]
9+
authorize_resource :project, except: %i[create]
1110

1211
before_action :ensure_create_is_a_remix, only: %i[create]
12+
before_action :load_original_project, only: %i[create]
13+
authorize_resource :original_project, only: %i[create]
1314

1415
def show
1516
render json: @project.scratch_component.content_with_stage_first
1617
end
1718

1819
def create
19-
original_project = load_original_project(source_project_identifier)
20-
return render json: { error: I18n.t('errors.admin.unauthorized') }, status: :unauthorized unless current_ability.can?(:show, original_project)
21-
20+
original_project = @original_project
2221
remix_params = create_params
2322
return render json: { error: I18n.t('errors.project.remixing.invalid_params') }, status: :bad_request if remix_params.dig(:scratch_component, :content).blank?
2423

@@ -74,8 +73,8 @@ def create_params
7473
}
7574
end
7675

77-
def load_original_project(identifier)
78-
Project.find_by!(identifier:, project_type: Project::Types::CODE_EDITOR_SCRATCH)
76+
def load_original_project
77+
@original_project = Project.find_by!(identifier: source_project_identifier, project_type: Project::Types::CODE_EDITOR_SCRATCH)
7978
end
8079

8180
def scratch_content_params
@@ -100,6 +99,10 @@ def move_pending_scratch_upload_to_remix(pending_upload, remix_project)
10099
rescue ActiveRecord::RecordNotUnique
101100
pending_upload.destroy!
102101
end
102+
103+
def load_project
104+
@project = Project.find_by!(identifier: params[:id], project_type: Project::Types::CODE_EDITOR_SCRATCH)
105+
end
103106
end
104107
end
105108
end

app/controllers/api/scratch/scratch_controller.rb

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,13 @@ module Api
44
module Scratch
55
class ScratchController < ApiController
66
before_action :authorize_user
7-
before_action :check_scratch_feature
7+
before_action :only_allow_schools_to_use_scratch
88

9-
def check_scratch_feature
10-
return if current_user.nil?
11-
12-
school = current_user&.schools&.first
13-
return if Flipper.enabled?(:cat_mode, school)
9+
def only_allow_schools_to_use_scratch
10+
return true if current_user.schools.any?
1411

1512
raise ActiveRecord::RecordNotFound, 'Not Found'
1613
end
17-
18-
def load_project
19-
@project = Project.find_by!(identifier: params[:id], project_type: Project::Types::CODE_EDITOR_SCRATCH)
20-
end
2114
end
2215
end
2316
end

app/views/api/schools/_school.json.jbuilder

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ json.call(
1717
:country_code,
1818
:verified_at,
1919
:created_at,
20-
:updated_at
20+
:updated_at,
21+
:scratch_enabled
2122
)
2223

2324
include_roles = local_assigns.fetch(:roles, false)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
class AddScratchEnabledToSchool < ActiveRecord::Migration[8.1]
2+
def change
3+
add_column :schools, :scratch_enabled, :boolean, default: false, null: false
4+
end
5+
end

db/schema.rb

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

spec/features/lesson/creating_a_lesson_spec.rb

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@
9494
new_params = { lesson: params[:lesson].merge(user_id: 'ignored') }
9595

9696
post('/api/lessons', headers:, params: new_params)
97+
expect(response).to have_http_status(:created)
9798
data = JSON.parse(response.body, symbolize_names: true)
9899

99100
expect(data[:user_id]).to eq(teacher.id)
@@ -186,4 +187,44 @@
186187
expect(response).to have_http_status(:unprocessable_content)
187188
end
188189
end
190+
191+
describe 'working with Scratch projects' do
192+
let(:params) do
193+
{
194+
lesson: {
195+
name: 'Test Lesson',
196+
school_id: school.id,
197+
project_attributes: {
198+
name: 'Hello Scratch project',
199+
project_type: Project::Types::CODE_EDITOR_SCRATCH,
200+
scratch_component: {
201+
content: {
202+
example_data: 'true'
203+
}
204+
}
205+
}
206+
}
207+
}
208+
end
209+
210+
it 'creates a lesson with a scratch component when school has Scratch enabled' do
211+
school.update!(scratch_enabled: true)
212+
post('/api/lessons', headers:, params:)
213+
expect(response).to have_http_status(:created)
214+
215+
data = JSON.parse(response.body, symbolize_names: true)
216+
217+
lesson_id = data[:id]
218+
219+
project = Lesson.find(lesson_id).project
220+
expect(project.project_type).to eq(Project::Types::CODE_EDITOR_SCRATCH)
221+
expect(project.scratch_component.content).to eq({ 'example_data' => 'true' })
222+
end
223+
224+
it 'returns forbidden when school does not have Scratch enabled' do
225+
school.update!(scratch_enabled: false)
226+
post('/api/lessons', headers:, params:)
227+
expect(response).to have_http_status(:forbidden)
228+
end
229+
end
189230
end

spec/features/lesson/updating_a_lesson_spec.rb

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,15 +117,17 @@
117117
expect(response).to have_http_status(:ok)
118118
end
119119

120-
it 'responds 422 Unprocessable Entity when trying to re-assign the lesson to a different class' do
120+
it 'does not allow re-assigning the lesson to a different class' do
121121
school = create(:school, id: SecureRandom.uuid)
122122
teacher = create(:teacher, school:)
123123
school_class = create(:school_class, school:, teacher_ids: [teacher.id])
124124

125125
new_params = { lesson: params[:lesson].merge(school_class_id: school_class.id) }
126126
put("/api/lessons/#{lesson.id}", headers:, params: new_params)
127+
expect(response).to have_http_status(:ok)
127128

128-
expect(response).to have_http_status(:unprocessable_content)
129+
lesson.reload
130+
expect(lesson.school_class_id).not_to eq(school_class.id)
129131
end
130132
end
131133
end

0 commit comments

Comments
 (0)