From f5e2acb1b2f4c74c5320fadd640ae156b4d98106 Mon Sep 17 00:00:00 2001 From: Chris Zetter <253059100+zetter-rpf@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:04:07 +0100 Subject: [PATCH 1/4] Migrate ExCS finished projects to completed projects Previously Experience CS marked projects submitted by setting school_projects.finished, bypassing the state machine we use for other classroom projects. So it's clear that these are from a migration and not submitted by the student at the time that the transition happened, I've avoided setting the user id and added metadata to the transition. I've chosen to make the projects as completed rather than submitted as this is what the original backfill did when project states were introduced - the worry was changing them to submitted would create a backlog for teachers to review. fixup --- ...experience_cs_project_migrations_controller.rb | 15 +++++++++++++++ app/models/school_project.rb | 4 ++-- spec/models/school_project_spec.rb | 2 +- .../update_spec.rb | 12 ++++++++++++ 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/app/controllers/api/experience_cs_project_migrations_controller.rb b/app/controllers/api/experience_cs_project_migrations_controller.rb index 4a6feeedc..7ce83ae1b 100644 --- a/app/controllers/api/experience_cs_project_migrations_controller.rb +++ b/app/controllers/api/experience_cs_project_migrations_controller.rb @@ -35,6 +35,21 @@ def migrate_project! ) scratch_component = @project.scratch_component || @project.build_scratch_component scratch_component.update!(attributes.require(:scratch_component).slice(:content)) + convert_finished_flag_to_submission! + end + end + + def convert_finished_flag_to_submission! + school_project = @project.school_project + return unless school_project&.finished? + + school_project.transaction do + school_project.update!(finished: false) + if school_project.can_transition_to?(:complete) + school_project.transition_status_to!(:complete, nil, info: 'backfilled_from_finished') + else + Rails.logger.warn("School project #{school_project.id} cannot transition to complete, in state #{school_project.status}") + end end end diff --git a/app/models/school_project.rb b/app/models/school_project.rb index 152100bfa..f506b38a1 100644 --- a/app/models/school_project.rb +++ b/app/models/school_project.rb @@ -25,8 +25,8 @@ def status state_machine.current_state end - def transition_status_to!(new_status, user_id) - state_machine.transition_to!(new_status, metadata: { changed_by: user_id }) + def transition_status_to!(new_status, user_id, **metadata) + state_machine.transition_to!(new_status, metadata.merge(changed_by: user_id)) end def unread_feedback? diff --git a/spec/models/school_project_spec.rb b/spec/models/school_project_spec.rb index 8cc785498..20aca2749 100644 --- a/spec/models/school_project_spec.rb +++ b/spec/models/school_project_spec.rb @@ -38,7 +38,7 @@ it 'calls transition_to! on the state machine with the new status and user_id' do allow(state_machine).to receive(:transition_to!) school_project.transition_status_to!(:submitted, student.id) - expect(state_machine).to have_received(:transition_to!).with(:submitted, metadata: { changed_by: student.id }) + expect(state_machine).to have_received(:transition_to!).with(:submitted, { changed_by: student.id }) end end diff --git a/spec/requests/experience_cs_project_migrations/update_spec.rb b/spec/requests/experience_cs_project_migrations/update_spec.rb index 26dfaa03a..53aa97a54 100644 --- a/spec/requests/experience_cs_project_migrations/update_spec.rb +++ b/spec/requests/experience_cs_project_migrations/update_spec.rb @@ -53,6 +53,18 @@ expect(project.scratch_component.content.to_h).to eq(scratch_data.deep_stringify_keys) end + it 'converts a finished flag into a complete' do + project.school_project.update!(finished: true) + + put(path, params:, headers:, as: :json) + + expect(response).to have_http_status(:ok) + school_project = project.reload.school_project + expect(school_project).to have_attributes(finished: false, status: 'complete') + expect(school_project.school_project_transitions.order(:sort_key).last.metadata) + .to include('info' => 'backfilled_from_finished') + end + it 'rejects a replay without overwriting Code Classroom changes' do put(path, params:, headers:, as: :json) code_classroom_data = scratch_data.merge(meta: { updated_in_code_classroom: true }) From 8bb8976b9aa93038803e71c9bd7d5661d830d643 Mon Sep 17 00:00:00 2001 From: Chris Zetter <253059100+zetter-rpf@users.noreply.github.com> Date: Thu, 17 Sep 2026 09:22:36 +0100 Subject: [PATCH 2/4] Stop ExCS migration filling the Salesforce sync queue Previously Every ExCS project migration would a Salesforce::LessonSyncJob, because updating the school project to set finished to false would trigger after_commit sync. Because we are swapping a finished count to a submitted, the number we sync to salesforce won't change. Migrating thousands of projects would queue thousands of syncs for data that has not meaningfully changed. I've done this by using a request attribute that is set and restored in the block --- ...rience_cs_project_migrations_controller.rb | 22 ++++++++++--------- app/models/current.rb | 5 +++++ lib/feature_flags.rb | 6 +++++ .../update_spec.rb | 19 +++++++++++++++- 4 files changed, 41 insertions(+), 11 deletions(-) create mode 100644 app/models/current.rb diff --git a/app/controllers/api/experience_cs_project_migrations_controller.rb b/app/controllers/api/experience_cs_project_migrations_controller.rb index 7ce83ae1b..cca7959bb 100644 --- a/app/controllers/api/experience_cs_project_migrations_controller.rb +++ b/app/controllers/api/experience_cs_project_migrations_controller.rb @@ -25,17 +25,19 @@ def load_project def migrate_project! attributes = migration_params - @project.with_lock do - authorize! :migrate_from_experience_cs, @project - @project.update!( - attributes.slice(:name, :instructions).merge( - project_type: Project::Types::CODE_EDITOR_SCRATCH, - origin: Project::Origins::EXPERIENCE_CS + FeatureFlags.without_salesforce_sync do + @project.with_lock do + authorize! :migrate_from_experience_cs, @project + @project.update!( + attributes.slice(:name, :instructions).merge( + project_type: Project::Types::CODE_EDITOR_SCRATCH, + origin: Project::Origins::EXPERIENCE_CS + ) ) - ) - scratch_component = @project.scratch_component || @project.build_scratch_component - scratch_component.update!(attributes.require(:scratch_component).slice(:content)) - convert_finished_flag_to_submission! + scratch_component = @project.scratch_component || @project.build_scratch_component + scratch_component.update!(attributes.require(:scratch_component).slice(:content)) + convert_finished_flag_to_submission! + end end end diff --git a/app/models/current.rb b/app/models/current.rb new file mode 100644 index 000000000..afb329461 --- /dev/null +++ b/app/models/current.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class Current < ActiveSupport::CurrentAttributes + attribute :salesforce_sync_suppressed +end diff --git a/lib/feature_flags.rb b/lib/feature_flags.rb index f43397fca..9bdc7125c 100644 --- a/lib/feature_flags.rb +++ b/lib/feature_flags.rb @@ -2,6 +2,12 @@ module FeatureFlags def self.salesforce_sync? + return false if Current.salesforce_sync_suppressed + ENV['SALESFORCE_ENABLED'] == 'true' end + + def self.without_salesforce_sync(&) + Current.set(salesforce_sync_suppressed: true, &) + end end diff --git a/spec/requests/experience_cs_project_migrations/update_spec.rb b/spec/requests/experience_cs_project_migrations/update_spec.rb index 53aa97a54..615f01f94 100644 --- a/spec/requests/experience_cs_project_migrations/update_spec.rb +++ b/spec/requests/experience_cs_project_migrations/update_spec.rb @@ -3,6 +3,8 @@ require 'rails_helper' RSpec.describe 'Experience CS project migration requests' do + include ActiveJob::TestHelper + let(:headers) { { ExperienceCsServiceAuthenticator::HEADER => 'service-api-key' } } let(:school) { create(:school) } let(:owner) { create(:teacher, school:) } @@ -13,7 +15,8 @@ school:, user_id: owner.id, locale: nil, - project_type: Project::Types::SCRATCH + project_type: Project::Types::SCRATCH, + lesson: create(:lesson, school: school, user_id: owner.id) ) end let(:scratch_data) { { targets: [], monitors: [], extensions: [], meta: {} } } @@ -65,6 +68,20 @@ .to include('info' => 'backfilled_from_finished') end + it 'does not run salesforce sync' do + project.school_project.update!(finished: true) + + allow(Salesforce::LessonSyncJob).to receive(:perform_later) + + ClimateControl.modify(SALESFORCE_ENABLED: 'true') do + put(path, params:, headers:, as: :json) + end + + expect(Salesforce::LessonSyncJob).not_to have_received(:perform_later) + + expect(response).to have_http_status(:ok) + end + it 'rejects a replay without overwriting Code Classroom changes' do put(path, params:, headers:, as: :json) code_classroom_data = scratch_data.merge(meta: { updated_in_code_classroom: true }) From 7ac95316b349f05edac0aba9b1cc5d1ef176b769 Mon Sep 17 00:00:00 2001 From: Chris Zetter <253059100+zetter-rpf@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:12:43 +0100 Subject: [PATCH 3/4] Fix salesforce connect database I was trying to run this locally and it didn't work because it was pointing to the wrong directory --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 45943eeb5..d57d622f4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -63,7 +63,7 @@ services: salesforce_connect: image: ghcr.io/raspberrypifoundation/heroku-connect volumes: - - salesforce_connect_data:/var/lib/postgres/data/ + - salesforce_connect_data:/var/lib/postgresql/data environment: - POSTGRES_DB=salesforce_development - POSTGRES_CLONE_DB=salesforce_test From cecab6f98f04742d532cd19733371646375f2e3c Mon Sep 17 00:00:00 2001 From: Chris Zetter <253059100+zetter-rpf@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:15:15 +0100 Subject: [PATCH 4/4] Count completed projects when syncing to salesforce The intention of this number is that it's projects that have been completed by students, while the finished_projects count only tracks projects that are waiting for teacher feedback. --- app/jobs/salesforce/lesson_sync_job.rb | 5 +---- app/models/lesson.rb | 4 ++++ spec/jobs/salesforce/lesson_sync_job_spec.rb | 18 +++++++++++------- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/app/jobs/salesforce/lesson_sync_job.rb b/app/jobs/salesforce/lesson_sync_job.rb index c5035a9ac..88c6378df 100644 --- a/app/jobs/salesforce/lesson_sync_job.rb +++ b/app/jobs/salesforce/lesson_sync_job.rb @@ -31,10 +31,7 @@ def sf_lesson_attributes(lesson:) teacherprojecttitle__c: lesson.project&.name, teacherprojecttype__c: project_type_attribute(lesson), numberofassignedprojects__c: assigned_projects_count(lesson), - # Sum of the two completion paths: state-machine `:submitted` (Code Editor flow) - # and `school_projects.finished` (Experience CS flow). They are mutually exclusive - # per project, so the sum is safe. - numberofcompletedprojects__c: lesson.submitted_projects_count + lesson.finished_projects_count, + numberofcompletedprojects__c: lesson.submitted_projects_count + lesson.finished_projects_count + lesson.completed_projects_count, lastsyncdate__c: Time.current ).to_h do |sf_field, value| value = truncate_value(sf_field:, value:) if value.is_a?(String) diff --git a/app/models/lesson.rb b/app/models/lesson.rb index 89127c7dc..76a906a6c 100644 --- a/app/models/lesson.rb +++ b/app/models/lesson.rb @@ -44,6 +44,10 @@ def recalculate_submitted_projects_count! end end + def completed_projects_count + school_projects.in_state(:complete).count + end + def finished_projects_count school_projects.where(finished: true).count end diff --git a/spec/jobs/salesforce/lesson_sync_job_spec.rb b/spec/jobs/salesforce/lesson_sync_job_spec.rb index 495f17575..1ce96228f 100644 --- a/spec/jobs/salesforce/lesson_sync_job_spec.rb +++ b/spec/jobs/salesforce/lesson_sync_job_spec.rb @@ -108,15 +108,19 @@ expect(sf_lesson.numberofcompletedprojects__c).to eq(1) end - it 'sums state-machine submissions and Experience CS finishes' do - lesson.update!(submitted_projects_count: 4) - 2.times do - finished_remix = create(:project, school:, user_id: student.id, remixed_from_id: lesson.project.id) - finished_remix.school_project.update!(finished: true) - end + it 'sums submissions, completed and finished projects' do + lesson.update!(submitted_projects_count: 1) + + finished_remix = create(:project, school:, user_id: student.id, remixed_from_id: lesson.project.id) + finished_remix.school_project.update!(finished: true) + + submitted_remix = create(:project, school:, user_id: student.id, remixed_from_id: lesson.project.id) + submitted_remix.school_project.transition_status_to!(:complete, nil) + perform_job + sf_lesson = Salesforce::Lesson.find_by(lesson_uuid__c: lesson.id) - expect(sf_lesson.numberofcompletedprojects__c).to eq(6) + expect(sf_lesson.numberofcompletedprojects__c).to eq(3) end end