diff --git a/app/controllers/api/experience_cs_project_migrations_controller.rb b/app/controllers/api/experience_cs_project_migrations_controller.rb index 4a6feeedc..cca7959bb 100644 --- a/app/controllers/api/experience_cs_project_migrations_controller.rb +++ b/app/controllers/api/experience_cs_project_migrations_controller.rb @@ -25,16 +25,33 @@ 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)) + 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 + + 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/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/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/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/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/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 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/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 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..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: {} } } @@ -53,6 +56,32 @@ 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 '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 })