From 811eef5dd3673d879d874fd0dd40846b3b253955 Mon Sep 17 00:00:00 2001 From: cocomarine Date: Thu, 20 Aug 2026 13:38:55 +0100 Subject: [PATCH 1/5] add origin column to projects --- db/migrate/20260820122510_add_origin_to_projects.rb | 8 ++++++++ db/schema.rb | 4 +++- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20260820122510_add_origin_to_projects.rb diff --git a/db/migrate/20260820122510_add_origin_to_projects.rb b/db/migrate/20260820122510_add_origin_to_projects.rb new file mode 100644 index 000000000..c8cf27478 --- /dev/null +++ b/db/migrate/20260820122510_add_origin_to_projects.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddOriginToProjects < ActiveRecord::Migration[8.1] + def change + add_column :projects, :origin, :string + add_index :projects, :origin, where: 'origin IS NOT NULL' + end +end diff --git a/db/schema.rb b/db/schema.rb index 0403b6037..df7b18044 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_07_120000) do +ActiveRecord::Schema[8.1].define(version: 2026_08_20_122510) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pgcrypto" @@ -240,6 +240,7 @@ t.uuid "lesson_id" t.string "locale" t.string "name" + t.string "origin" t.string "project_type", default: "python", null: false t.string "remix_origin" t.uuid "remixed_from_id" @@ -249,6 +250,7 @@ t.index ["identifier", "locale"], name: "index_projects_on_identifier_and_locale", unique: true t.index ["identifier"], name: "index_projects_on_identifier" t.index ["lesson_id"], name: "index_projects_on_lesson_id" + t.index ["origin"], name: "index_projects_on_origin", where: "(origin IS NOT NULL)" t.index ["remixed_from_id"], name: "index_projects_on_remixed_from_id" t.index ["school_id"], name: "index_projects_on_school_id" end From cea08d7b38f658ba85bc888f75cd8a47f211f470 Mon Sep 17 00:00:00 2001 From: cocomarine Date: Thu, 20 Aug 2026 14:45:21 +0100 Subject: [PATCH 2/5] add validations and test --- app/models/project.rb | 14 ++++++++++++++ spec/models/project_spec.rb | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/app/models/project.rb b/app/models/project.rb index 1da4542bb..23fbac5a4 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -8,6 +8,10 @@ module Types CODE_EDITOR_SCRATCH = 'code_editor_scratch' end + module Origins + EXPERIENCE_CS = 'experience_cs' + end + belongs_to :school, optional: true belongs_to :lesson, optional: true belongs_to :parent, optional: true, class_name: :Project, foreign_key: :remixed_from_id, inverse_of: :remixes @@ -35,6 +39,8 @@ module Types validate :project_with_instructions_must_belong_to_school validate :project_with_school_id_has_school_project validate :school_project_school_matches_project_school + validates :origin, inclusion: { in: [Origins::EXPERIENCE_CS], allow_nil: true } + validate :origin_cannot_change, on: :update scope :internal_projects, -> { where(user_id: nil) } @@ -182,4 +188,12 @@ def school_project_school_matches_project_school errors.add(:school_project, 'School project school_id must match project school_id') end + + def origin_cannot_change + return unless origin_changed? + # allow filling in origin when it's nil + return if origin_was.nil? + + errors.add(:origin, 'cannot be changed once set') + end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 14ca4c4ad..acdbae0ca 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -53,6 +53,21 @@ expect(valid_project).to be_valid end + it 'is valid without an origin' do + valid_project = build(:project, origin: nil) + expect(valid_project).to be_valid + end + + it 'is valid with a known origin' do + valid_project = build(:project, origin: Project::Origins::EXPERIENCE_CS) + expect(valid_project).to be_valid + end + + it 'is invalid with an unrecognised origin' do + invalid_project = build(:project, origin: 'invalid_origin') + expect(invalid_project).not_to be_valid + end + it 'allows a public Code Classroom Blocks project to have instructions' do project = build( :project, @@ -197,6 +212,24 @@ end end + describe 'origin_cannot_change' do + it 'allows an origin to be set on create' do + expect { create(:project, origin: Project::Origins::EXPERIENCE_CS) }.not_to raise_error + end + + it 'allows an origin to be set on a project that does not have one' do + project = create(:project, origin: nil) + expect { project.update!(origin: Project::Origins::EXPERIENCE_CS) }.not_to raise_error + end + + it 'does not allow an origin to be updated once set' do + project = create(:project, origin: Project::Origins::EXPERIENCE_CS) + + expect(project.update(origin: nil)).to be(false) + expect(project.errors[:origin]).to include(/cannot be changed once set/) + end + end + describe 'create_school_project_if_needed' do let(:teacher) { create(:teacher, school:) } let(:teacher_project) { create(:project, school_id: school.id, user_id: teacher.id) } From a07892260a5d7fc7f12b3457ae678cdf1b824f42 Mon Sep 17 00:00:00 2001 From: cocomarine Date: Thu, 20 Aug 2026 16:48:06 +0100 Subject: [PATCH 3/5] add origin when project created by ExCS admin and add tests --- lib/concepts/project/operations/create.rb | 7 ++++- spec/concepts/project/create_remix_spec.rb | 14 +++++++++ spec/concepts/project/create_spec.rb | 30 +++++++++++++++++++ .../project/creating_a_project_spec.rb | 6 ++++ 4 files changed, 56 insertions(+), 1 deletion(-) diff --git a/lib/concepts/project/operations/create.rb b/lib/concepts/project/operations/create.rb index 6c4c9e5cc..c8247bb56 100644 --- a/lib/concepts/project/operations/create.rb +++ b/lib/concepts/project/operations/create.rb @@ -17,7 +17,12 @@ def call(project_hash:, current_user:) private def build_project(project_hash, current_user) - project_hash[:identifier] = PhraseIdentifier.generate unless current_user&.experience_cs_admin? + if current_user&.experience_cs_admin? + project_hash[:origin] = Project::Origins::EXPERIENCE_CS + else + project_hash[:identifier] = PhraseIdentifier.generate + end + new_project = Project.new(project_hash.except(:components, :scratch_component)) new_project.components.build(project_hash[:components]) new_project.build_scratch_component(project_hash[:scratch_component]) if project_hash[:scratch_component].present? diff --git a/spec/concepts/project/create_remix_spec.rb b/spec/concepts/project/create_remix_spec.rb index 50cc4a170..0cece7ff3 100644 --- a/spec/concepts/project/create_remix_spec.rb +++ b/spec/concepts/project/create_remix_spec.rb @@ -74,6 +74,20 @@ expect(remixed_project.remix_origin).to eq(remix_origin) end + it 'does not set an origin when the original project has none' do + remixed_project = create_remix[:project] + expect(remixed_project.origin).to be_nil + end + + context 'when the original project has an origin' do + let!(:original_project) { create(:project, :with_components, origin: Project::Origins::EXPERIENCE_CS) } + + it 'copies the origin to the remix' do + remixed_project = create_remix[:project] + expect(remixed_project.origin).to eq(Project::Origins::EXPERIENCE_CS) + end + end + it 'links remix to attached images' do remixed_project = create_remix[:project] expect(remixed_project.images.length).to eq(original_project.images.length) diff --git a/spec/concepts/project/create_spec.rb b/spec/concepts/project/create_spec.rb index 519b7d788..697217114 100644 --- a/spec/concepts/project/create_spec.rb +++ b/spec/concepts/project/create_spec.rb @@ -40,6 +40,36 @@ new_project = create_project_with_content[:project] expect(new_project.components.first.content).to eq('print("hello world")') end + + it 'does not set the project origin' do + expect(create_project_with_content[:project].origin).to be_nil + end + end + + context 'when the current user is an Experience CS admin' do + subject(:create_project_as_admin) { described_class.call(project_hash:, current_user:) } + + let(:current_user) { create(:experience_cs_admin_user) } + let(:project_hash) do + { + project_type: Project::Types::PYTHON, + components: [{ + name: 'main', + extension: 'py', + content: 'print("hello world")', + default: true + }], + user_id: + } + end + + it 'returns success' do + expect(create_project_as_admin.success?).to be(true) + end + + it 'sets the project origin to experience_cs' do + expect(create_project_as_admin[:project].origin).to eq(Project::Origins::EXPERIENCE_CS) + end end context 'when creation fails' do diff --git a/spec/features/project/creating_a_project_spec.rb b/spec/features/project/creating_a_project_spec.rb index 459272f65..c5d4b8fd2 100644 --- a/spec/features/project/creating_a_project_spec.rb +++ b/spec/features/project/creating_a_project_spec.rb @@ -308,5 +308,11 @@ project = Project.find_by!(identifier: 'test-project', locale: 'fr') expect(project.scratch_component.content.to_h).to eq(scratch_data.deep_stringify_keys) end + + it 'sets the project origin to experience_cs' do + post('/api/projects', headers:, params:, as: :json) + + expect(Project.find_by!(identifier: 'test-project', locale: 'fr').origin).to eq(Project::Origins::EXPERIENCE_CS) + end end end From 8943e3c67cade01e9db3119ed2b59c1f07c4122f Mon Sep 17 00:00:00 2001 From: cocomarine Date: Mon, 24 Aug 2026 14:56:43 +0100 Subject: [PATCH 4/5] update migration to add index concurrently --- db/migrate/20260820122510_add_origin_to_projects.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/db/migrate/20260820122510_add_origin_to_projects.rb b/db/migrate/20260820122510_add_origin_to_projects.rb index c8cf27478..fe361d295 100644 --- a/db/migrate/20260820122510_add_origin_to_projects.rb +++ b/db/migrate/20260820122510_add_origin_to_projects.rb @@ -1,8 +1,12 @@ # frozen_string_literal: true class AddOriginToProjects < ActiveRecord::Migration[8.1] + disable_ddl_transaction! + def change - add_column :projects, :origin, :string - add_index :projects, :origin, where: 'origin IS NOT NULL' + add_column :projects, :origin, :string, if_not_exists: true + add_index :projects, :origin, where: 'origin IS NOT NULL', + algorithm: :concurrently, + if_not_exists: true end end From 089c97a302e7e98485e5bf0181813a901b24ea5a Mon Sep 17 00:00:00 2001 From: cocomarine Date: Mon, 24 Aug 2026 15:30:06 +0100 Subject: [PATCH 5/5] remove index from migration --- db/migrate/20260820122510_add_origin_to_projects.rb | 7 +------ db/schema.rb | 1 - 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/db/migrate/20260820122510_add_origin_to_projects.rb b/db/migrate/20260820122510_add_origin_to_projects.rb index fe361d295..aebb0bb1a 100644 --- a/db/migrate/20260820122510_add_origin_to_projects.rb +++ b/db/migrate/20260820122510_add_origin_to_projects.rb @@ -1,12 +1,7 @@ # frozen_string_literal: true class AddOriginToProjects < ActiveRecord::Migration[8.1] - disable_ddl_transaction! - def change - add_column :projects, :origin, :string, if_not_exists: true - add_index :projects, :origin, where: 'origin IS NOT NULL', - algorithm: :concurrently, - if_not_exists: true + add_column :projects, :origin, :string end end diff --git a/db/schema.rb b/db/schema.rb index df7b18044..e21d13889 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -250,7 +250,6 @@ t.index ["identifier", "locale"], name: "index_projects_on_identifier_and_locale", unique: true t.index ["identifier"], name: "index_projects_on_identifier" t.index ["lesson_id"], name: "index_projects_on_lesson_id" - t.index ["origin"], name: "index_projects_on_origin", where: "(origin IS NOT NULL)" t.index ["remixed_from_id"], name: "index_projects_on_remixed_from_id" t.index ["school_id"], name: "index_projects_on_school_id" end