Skip to content
Open
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
2 changes: 2 additions & 0 deletions app/models/project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ module Origins
belongs_to :lesson, optional: true
belongs_to :parent, optional: true, class_name: :Project, foreign_key: :remixed_from_id, inverse_of: :remixes
has_many :remixes, dependent: :nullify, class_name: :Project, foreign_key: :remixed_from_id, inverse_of: :parent
belongs_to :source_project, optional: true, class_name: :Project, inverse_of: :derived_projects
has_many :derived_projects, dependent: :nullify, class_name: :Project, foreign_key: :source_project_id, inverse_of: :source_project
has_many :components, -> { order(default: :desc, name: :asc) }, dependent: :destroy, inverse_of: :project
has_one :scratch_component, dependent: :destroy, inverse_of: :project, required: false
has_many :scratch_assets, dependent: :destroy
Expand Down
9 changes: 9 additions & 0 deletions db/migrate/20260828095502_add_source_project_to_projects.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true

class AddSourceProjectToProjects < ActiveRecord::Migration[8.1]
def change
add_reference :projects, :source_project,
type: :uuid,
foreign_key: { to_table: :projects, on_delete: :nullify }
end
end
5 changes: 4 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion lib/concepts/lesson/operations/create.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ def attributes_for_copy(source_project, project_params)
name: project_params[:name].presence || source_project.name,
user_id: project_params[:user_id],
school_id: project_params[:school_id],
lesson_id: project_params[:lesson_id]
lesson_id: project_params[:lesson_id],
source_project_id: source_project.id,
remixed_from_id: nil,
remix_origin: nil
Comment thread
cocomarine marked this conversation as resolved.
}
end
end
Expand Down
16 changes: 16 additions & 0 deletions spec/concepts/lesson/create_copy_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,22 @@
expect(copied_component.content).to eq(original_component.content)
end

context 'when copying a lesson whose project has a source_project' do
let!(:source_project) { create(:project, :with_components) }
let!(:original_project) { create(:project, :with_components, source_project:) }

before do
lesson.project = original_project
lesson.save!
end

it 'copies the source_project to the new project' do
response = described_class.call(lesson:, lesson_params:)
copied_project = response[:lesson].reload.project
expect(copied_project.source_project_id).to eq(source_project.id)
end
end

context 'when the project is a Scratch project' do
let(:copied_teacher_id) { SecureRandom.uuid }
let(:lesson_params) { { user_id: copied_teacher_id } }
Expand Down
23 changes: 23 additions & 0 deletions spec/concepts/lesson/create_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,10 @@
expect(lesson_project.name).to eq('My digital canvas')
end

it 'records the source project id on the lesson project' do
expect(lesson_project.source_project_id).to eq(source_project.id)
end

it 'does not change the source project' do
expect { response }.not_to(change { source_project.reload.attributes })
end
Expand All @@ -274,6 +278,25 @@
expect { response }.not_to change(ScratchAsset, :count)
end

context 'when the source project is itself a remix' do
let(:source_project_fr) do
create(:scratch_project, identifier: source_project_en.identifier, locale: 'fr-FR', user_id: nil,
name: 'Ma toile numérique', origin: Project::Origins::EXPERIENCE_CS,
instructions: 'Instructions en français',
remixed_from_id: create(:project).id,
remix_origin: 'example.com')
.tap { |project| project.scratch_component.update!(content: french_content) }
end

it 'does not inherit remixed_from_id from the source project' do
expect(lesson_project.remixed_from_id).to be_nil
end

it 'does not inherit remix_origin from the source project' do
expect(lesson_project.remix_origin).to be_nil
end
end

context 'when the source project has no origin' do
let(:source_project) do
create(:scratch_project, identifier: 'not-backfilled-yet', locale: 'en', user_id: nil, origin: nil)
Expand Down
15 changes: 15 additions & 0 deletions spec/concepts/project/create_remix_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,21 @@
end
end

context 'when remixing a project that has a source_project' do
let!(:source_project) { create(:project, :with_components) }
let!(:original_project) { create(:project, :with_components, source_project:) }

it 'copies the source_project to the remix' do
remixed_project = create_remix[:project]
expect(remixed_project.source_project_id).to eq(source_project.id)
end

it 'sets remixed_from_id to the immediate parent, not the source project' do
remixed_project = create_remix[:project]
expect(remixed_project.remixed_from_id).to eq(original_project.id)
end
end

context 'when user_id is not present' do
let(:user_id) { nil }
let(:params) { { project_id: original_project.identifier } }
Expand Down
19 changes: 19 additions & 0 deletions spec/models/project_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@
it { is_expected.to belong_to(:school).optional(true) }
it { is_expected.to belong_to(:lesson).optional(true) }
it { is_expected.to belong_to(:parent).optional(true) }
it { is_expected.to belong_to(:source_project).optional(true) }
it { is_expected.to have_many(:remixes).dependent(:nullify) }
it { is_expected.to have_many(:components) }
it { is_expected.to have_many(:scratch_assets).dependent(:destroy) }
it { is_expected.to have_many(:project_errors).dependent(:nullify) }
it { is_expected.to have_many(:derived_projects).dependent(:nullify) }
it { is_expected.to have_many_attached(:images) }
it { is_expected.to have_many_attached(:videos) }
it { is_expected.to have_many_attached(:audio) }
Expand Down Expand Up @@ -68,6 +70,11 @@
expect(invalid_project).not_to be_valid
end

it 'is valid without a source project' do
valid_project = build(:project, source_project: nil)
expect(valid_project).to be_valid
end

it 'allows a public Code Classroom Blocks project to have instructions' do
project = build(
:project,
Expand Down Expand Up @@ -230,6 +237,18 @@
end
end

describe 'source project lineage' do
let(:source_project) { create(:project) }

it 'nullifies source_project_id on derived projects when the source project is destroyed' do
project = create(:project, source_project:)

source_project.destroy!

expect(project.reload.source_project_id).to be_nil
end
end

describe '#public_experience_cs_project?' do
it 'returns true for public Experience CS project types', :aggregate_failures do
project_types = [described_class::Types::SCRATCH, described_class::Types::CODE_EDITOR_SCRATCH]
Expand Down
Loading