diff --git a/app/models/project.rb b/app/models/project.rb index 73a911856..4c518cdfa 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -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 diff --git a/db/migrate/20260828095502_add_source_project_to_projects.rb b/db/migrate/20260828095502_add_source_project_to_projects.rb new file mode 100644 index 000000000..15dcbb7a7 --- /dev/null +++ b/db/migrate/20260828095502_add_source_project_to_projects.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index e21d13889..6ab773e2a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_08_20_122510) do +ActiveRecord::Schema[8.1].define(version: 2026_08_28_095502) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pgcrypto" @@ -245,6 +245,7 @@ t.string "remix_origin" t.uuid "remixed_from_id" t.uuid "school_id" + t.uuid "source_project_id" t.datetime "updated_at", null: false t.uuid "user_id" t.index ["identifier", "locale"], name: "index_projects_on_identifier_and_locale", unique: true @@ -252,6 +253,7 @@ t.index ["lesson_id"], name: "index_projects_on_lesson_id" t.index ["remixed_from_id"], name: "index_projects_on_remixed_from_id" t.index ["school_id"], name: "index_projects_on_school_id" + t.index ["source_project_id"], name: "index_projects_on_source_project_id" end create_table "roles", force: :cascade do |t| @@ -419,6 +421,7 @@ add_foreign_key "lessons", "schools" add_foreign_key "project_errors", "projects" add_foreign_key "projects", "lessons" + add_foreign_key "projects", "projects", column: "source_project_id", on_delete: :nullify add_foreign_key "projects", "schools" add_foreign_key "roles", "schools" add_foreign_key "school_classes", "schools" diff --git a/lib/concepts/lesson/operations/create.rb b/lib/concepts/lesson/operations/create.rb index 9c7e32ede..c5ed8eab4 100644 --- a/lib/concepts/lesson/operations/create.rb +++ b/lib/concepts/lesson/operations/create.rb @@ -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 } end end diff --git a/spec/concepts/lesson/create_copy_spec.rb b/spec/concepts/lesson/create_copy_spec.rb index b4e4a70d0..b2e9783bd 100644 --- a/spec/concepts/lesson/create_copy_spec.rb +++ b/spec/concepts/lesson/create_copy_spec.rb @@ -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 } } diff --git a/spec/concepts/lesson/create_spec.rb b/spec/concepts/lesson/create_spec.rb index d8dc75470..9dcd367d0 100644 --- a/spec/concepts/lesson/create_spec.rb +++ b/spec/concepts/lesson/create_spec.rb @@ -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 @@ -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) diff --git a/spec/concepts/project/create_remix_spec.rb b/spec/concepts/project/create_remix_spec.rb index 0cece7ff3..3099f5070 100644 --- a/spec/concepts/project/create_remix_spec.rb +++ b/spec/concepts/project/create_remix_spec.rb @@ -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 } } diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 7cc8d3a3b..48fcdacc9 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -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) } @@ -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, @@ -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]