Skip to content

Commit efc9f26

Browse files
authored
Implement remix endpoints (#746)
Closes: RaspberryPiFoundation/digital-editor-issues#1237 POST /api/scratch/projects now performs the real Scratch first-save remix flow for logged-in users with Scratch enabled. The endpoint is remix-only: it requires is_remix=1 and original_id, loads the original project from original_id, returns 401 if the current user cannot access that project, translates the Scratch save payload into scratch_component content, calls Project::CreateRemix, and returns the Scratch response shape: { "status": "ok", "content-name": "<new-id>" } Also included: - i18n for the new Scratch remix error messages - request coverage for the Scratch create endpoint - unit coverage for the Scratch remix branch in CreateRemix https://github.com/user-attachments/assets/0abc3f41-a333-413f-8adb-724ba3d8695e
1 parent c1f5481 commit efc9f26

7 files changed

Lines changed: 208 additions & 26 deletions

File tree

app/controllers/api/projects_controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ def show_context
6363
private
6464

6565
def set_auth_cookie_for_scratch
66-
return unless @project.project_type == Project::Types::CODE_EDITOR_SCRATCH
66+
return unless @project.scratch_project?
6767
return unless Flipper.enabled?(:cat_mode, school)
6868

6969
cookies[:scratch_auth] = {

app/controllers/api/scratch/projects_controller.rb

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,22 +14,65 @@ def show
1414
end
1515

1616
def create
17-
render json: { status: 'ok', 'content-name': 'new-project-id' }, status: :ok
17+
original_project = load_original_project(source_project_identifier)
18+
return render json: { error: I18n.t('errors.admin.unauthorized') }, status: :unauthorized unless current_ability.can?(:show, original_project)
19+
20+
remix_params = create_params
21+
return render json: { error: I18n.t('errors.project.remixing.invalid_params') }, status: :bad_request if remix_params.dig(:scratch_component, :content).blank?
22+
23+
remix_origin = request.origin || request.referer
24+
25+
result = Project::CreateRemix.call(
26+
params: remix_params,
27+
user_id: current_user.id,
28+
original_project:,
29+
remix_origin:
30+
)
31+
32+
if result.success?
33+
render json: { status: 'ok', 'content-name': result[:project].identifier }, status: :ok
34+
else
35+
render json: { error: result[:error] }, status: :bad_request
36+
end
1837
end
1938

2039
def update
21-
scratch_content = params.permit!.slice(:meta, :targets, :monitors, :extensions)
22-
@project.scratch_component&.content = scratch_content.to_unsafe_h
40+
@project.scratch_component&.content = scratch_content_params
2341
@project.save!
2442
render json: { status: 'ok' }, status: :ok
2543
end
2644

2745
private
2846

2947
def ensure_create_is_a_remix
30-
return if params[:is_remix] == '1'
48+
return if params[:is_remix] == '1' && params[:original_id].present?
49+
50+
render json: { error: I18n.t('errors.project.remixing.only_existing_allowed') }, status: :forbidden
51+
end
52+
53+
def source_project_identifier
54+
params[:original_id]
55+
end
56+
57+
def create_params
58+
{
59+
identifier: source_project_identifier,
60+
scratch_component: { content: scratch_content_params }
61+
}
62+
end
63+
64+
def load_original_project(identifier)
65+
project_loader = ProjectLoader.new(identifier, [params[:locale]])
66+
original_project = project_loader.load
67+
68+
raise ActiveRecord::RecordNotFound, I18n.t('errors.project.not_found') unless original_project
69+
raise ActiveRecord::RecordNotFound, I18n.t('errors.project.not_found') unless original_project.scratch_project?
70+
71+
original_project
72+
end
3173

32-
render json: { error: 'Only remixing existing projects is allowed' }, status: :forbidden
74+
def scratch_content_params
75+
params.slice(:meta, :targets, :monitors, :extensions).to_unsafe_h
3376
end
3477
end
3578
end

app/models/project.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ def media
8282
images + videos + audio
8383
end
8484

85+
def scratch_project?
86+
project_type == Types::CODE_EDITOR_SCRATCH
87+
end
88+
8589
private
8690

8791
def check_unique_not_null

config/locales/en.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ en:
44
unauthorized: "Not authorized."
55
csv_file_required: "A CSV file is required."
66
project:
7+
not_found: "Project not found"
78
editing:
89
delete_default_component: "Cannot delete default file"
910
change_default_name: "Cannot amend default file name"
@@ -12,6 +13,7 @@ en:
1213
remixing:
1314
invalid_params: "Invalid parameters"
1415
cannot_save: "Cannot create project remix"
16+
only_existing_allowed: "Only remixing existing projects is allowed"
1517
validations:
1618
school:
1719
website: "must be a valid URL"

lib/concepts/project/operations/create_remix.rb

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@ def validate_params(response, params, user_id, original_project, remix_origin)
2323
end
2424

2525
def remix_project(response, params, user_id, original_project, remix_origin)
26-
response[:project] = create_remix(original_project, params, user_id, remix_origin)
26+
response[:project] = if original_project.scratch_project?
27+
create_scratch_remix(original_project, params, user_id, remix_origin)
28+
else
29+
create_remix(original_project, params, user_id, remix_origin)
30+
end
2731
response[:project].save!
2832
response
2933
end
@@ -50,11 +54,19 @@ def create_remix(original_project, params, user_id, remix_origin)
5054
remix
5155
end
5256

57+
def create_scratch_remix(original_project, params, user_id, remix_origin)
58+
remix = format_project(original_project, params, user_id, remix_origin)
59+
scratch_component = params.fetch(:scratch_component)
60+
remix.build_scratch_component(content: scratch_component[:content])
61+
62+
remix
63+
end
64+
5365
def format_project(original_project, params, user_id, remix_origin)
5466
original_project.dup.tap do |proj|
5567
proj.identifier = PhraseIdentifier.generate
5668
proj.locale = nil
57-
proj.name = params[:name]
69+
proj.name = params[:name] || original_project.name
5870
proj.user_id = user_id
5971
proj.remixed_from_id = original_project.id
6072
proj.remix_origin = remix_origin

spec/concepts/project/create_remix_spec.rb

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,49 @@
129129
end
130130
end
131131

132+
context 'when the original project has Scratch content' do
133+
let!(:original_project) do
134+
create(:project, project_type: Project::Types::CODE_EDITOR_SCRATCH, locale: nil)
135+
end
136+
let(:original_scratch_project) do
137+
{
138+
meta: { semver: '3.0.0' },
139+
targets: ['original target'],
140+
monitors: [],
141+
extensions: []
142+
}
143+
end
144+
145+
before do
146+
create(:scratch_component, project: original_project, content: original_scratch_project)
147+
end
148+
149+
context 'when new Scratch content is provided' do
150+
let(:remixed_scratch_project) do
151+
{
152+
meta: { semver: '3.0.0' },
153+
targets: ['remixed target'],
154+
monitors: [],
155+
extensions: ['pen']
156+
}
157+
end
158+
let(:remix_params) do
159+
{
160+
name: 'My remixed project',
161+
identifier: original_project.identifier,
162+
scratch_component: {
163+
content: remixed_scratch_project
164+
}
165+
}
166+
end
167+
168+
it 'uses the supplied Scratch content' do
169+
expect(create_remix[:project].scratch_component.content.to_h)
170+
.to eq(remixed_scratch_project.deep_stringify_keys)
171+
end
172+
end
173+
end
174+
132175
context 'when user_id is not present' do
133176
let(:user_id) { nil }
134177
let(:params) { { project_id: original_project.identifier } }

spec/features/scratch/creating_a_scratch_project_spec.rb

Lines changed: 96 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,47 +5,125 @@
55
RSpec.describe 'Creating a Scratch project (remixing)', type: :request do
66
let(:school) { create(:school) }
77
let(:teacher) { create(:teacher, school:) }
8-
let(:cookie_headers) { { 'Cookie' => "scratch_auth=#{UserProfileMock::TOKEN}" } }
9-
let(:params) { { original_id: 'original-project-id', project: { targets: [] }, is_remix: '1' } }
8+
let(:headers) do
9+
{
10+
'Cookie' => "scratch_auth=#{UserProfileMock::TOKEN}",
11+
'Origin' => 'editor.com'
12+
}
13+
end
14+
let(:request_query) { { original_id: original_project.identifier, is_remix: '1' } }
15+
let(:scratch_project) do
16+
{
17+
meta: { semver: '3.0.0' },
18+
targets: ['updated target'],
19+
monitors: [],
20+
extensions: ['pen']
21+
}
22+
end
23+
let(:lesson) { create(:lesson, school:, user_id: teacher.id) }
24+
let(:original_project) do
25+
create(
26+
:project,
27+
school:,
28+
lesson:,
29+
user_id: teacher.id,
30+
project_type: Project::Types::CODE_EDITOR_SCRATCH,
31+
locale: nil
32+
)
33+
end
1034

1135
before do
36+
mock_phrase_generation('new-project-id')
37+
create(:scratch_component, project: original_project)
38+
1239
Flipper.disable :cat_mode
1340
Flipper.disable_actor :cat_mode, school
1441
end
1542

43+
def make_request(query: request_query, request_headers: headers, request_params: scratch_project)
44+
post(
45+
"/api/scratch/projects?#{Rack::Utils.build_query(query)}",
46+
params: request_params,
47+
headers: request_headers,
48+
as: :json
49+
)
50+
end
51+
1652
it 'responds 401 Unauthorized when no cookie is provided' do
17-
post '/api/scratch/projects', params: params
53+
make_request(request_headers: {})
1854

1955
expect(response).to have_http_status(:unauthorized)
2056
end
2157

2258
it 'responds 404 Not Found when cat_mode is not enabled' do
2359
authenticated_in_hydra_as(teacher)
2460

25-
post '/api/scratch/projects', params: params, headers: cookie_headers
61+
make_request
2662

2763
expect(response).to have_http_status(:not_found)
2864
end
2965

30-
it 'responds 403 Forbidden when not remixing' do
31-
authenticated_in_hydra_as(teacher)
32-
Flipper.enable_actor :cat_mode, school
66+
context 'when authenticated and cat_mode is enabled' do
67+
before do
68+
authenticated_in_hydra_as(teacher)
69+
Flipper.enable_actor :cat_mode, school
70+
end
3371

34-
post '/api/scratch/projects', params: params.merge(is_remix: '0'), headers: cookie_headers
72+
it 'responds 403 Forbidden when not remixing' do
73+
make_request(query: request_query.merge(is_remix: '0'))
3574

36-
expect(response).to have_http_status(:forbidden)
37-
end
75+
expect(response).to have_http_status(:forbidden)
76+
end
3877

39-
it 'return new project id when cat_mode is enabled and a cookie is provided' do
40-
authenticated_in_hydra_as(teacher)
41-
Flipper.enable_actor :cat_mode, school
78+
it 'responds 403 Forbidden when original_id is missing' do
79+
make_request(query: { is_remix: '1' })
80+
81+
expect(response).to have_http_status(:forbidden)
82+
end
83+
84+
it 'responds 404 Not Found when original project does not exist' do
85+
make_request(query: { original_id: 'no-such-project', is_remix: '1' })
86+
87+
expect(response).to have_http_status(:not_found)
88+
end
89+
90+
it 'responds 404 Not Found when the original project is not a Scratch project' do
91+
non_scratch_project = create(:project, school:, lesson:, user_id: teacher.id, locale: nil)
92+
93+
make_request(query: { original_id: non_scratch_project.identifier, is_remix: '1' })
94+
95+
expect(response).to have_http_status(:not_found)
96+
end
97+
98+
it 'responds 401 Unauthorized when the user cannot access the original project' do
99+
inaccessible_project = create(:project, project_type: Project::Types::CODE_EDITOR_SCRATCH, locale: nil)
100+
create(:scratch_component, project: inaccessible_project)
101+
102+
make_request(query: { original_id: inaccessible_project.identifier, is_remix: '1' })
103+
104+
expect(response).to have_http_status(:unauthorized)
105+
end
106+
107+
it 'responds 400 Bad Request when no Scratch content is submitted' do
108+
make_request(request_params: {})
109+
110+
expect(response).to have_http_status(:bad_request)
111+
end
42112

43-
post '/api/scratch/projects', params: params, headers: cookie_headers
113+
it 'creates a remix, associates it to the current user, and returns the new identifier' do
114+
expect { make_request }.to change(Project, :count).by(1)
44115

45-
expect(response).to have_http_status(:ok)
116+
expect(response).to have_http_status(:ok)
117+
expect(response.parsed_body).to eq(
118+
'status' => 'ok',
119+
'content-name' => 'new-project-id'
120+
)
46121

47-
data = JSON.parse(response.body, symbolize_names: true)
48-
expect(data[:status]).to eq('ok')
49-
expect(data[:'content-name']).to eq('new-project-id')
122+
remixed_project = Project.find_by!(identifier: 'new-project-id')
123+
expect(remixed_project.user_id).to eq(teacher.id)
124+
expect(remixed_project.remixed_from_id).to eq(original_project.id)
125+
expect(remixed_project.lesson_id).to be_nil
126+
expect(remixed_project.scratch_component.content.to_h).to eq(scratch_project.deep_stringify_keys)
127+
end
50128
end
51129
end

0 commit comments

Comments
 (0)