diff --git a/app/controllers/content_migrations_controller.rb b/app/controllers/content_migrations_controller.rb index 6f502a9405d10..f66b0aeaee463 100644 --- a/app/controllers/content_migrations_controller.rb +++ b/app/controllers/content_migrations_controller.rb @@ -167,6 +167,7 @@ def index OLD_START_DATE: datetime_string(@context.start_at, :verbose), OLD_END_DATE: datetime_string(@context.conclude_at, :verbose), SHOW_SELECT: should_show_course_copy_dropdown, + COPY_COURSE_INTEGRATION_INFO: Account.site_admin.feature_enabled?(:course_copy_allow_copying_integration_info), MISSING_POLICY_ENABLED: @context.late_policy&.missing_submission_deduction_enabled || false }) set_tutorial_js_env @@ -212,6 +213,7 @@ def index BLUEPRINT_ELIGIBLE_IMPORT: MasterCourses::MasterTemplate.blueprint_eligible?(@context), SHOW_BP_SETTINGS_IMPORT_OPTION: MasterCourses::MasterTemplate.blueprint_eligible?(@context) && @context.account.grants_all_rights?(@current_user, session, :manage_courses_admin, :manage_master_courses), + COPY_COURSE_INTEGRATION_INFO: Account.site_admin.feature_enabled?(:course_copy_allow_copying_integration_info), MISSING_POLICY_ENABLED: @context.late_policy&.missing_submission_deduction_enabled || false }) set_tutorial_js_env @@ -341,6 +343,10 @@ def migration_plugin_supported?(plugin) # from the source course or package. The destination course must not be associated # with an existing blueprint course and cannot have any student or observer enrollments. # + # @argument settings[copy_integration_info] [Boolean] + # Whether to copy integration_id and integration_data values from + # source assignments to destination assignments during a course copy. + # # @argument date_shift_options[shift_dates] [Boolean] # Whether to shift dates in the copied course # diff --git a/app/controllers/courses_controller.rb b/app/controllers/courses_controller.rb index 5f1eadb05394a..dcaad0dbb44d3 100644 --- a/app/controllers/courses_controller.rb +++ b/app/controllers/courses_controller.rb @@ -2984,7 +2984,8 @@ def copy NEW_QUIZZES_MIGRATION: new_quizzes_migration_enabled?, NEW_QUIZZES_MIGRATION_DEFAULT: new_quizzes_migration_default, NEW_QUIZZES_MIGRATION_REQUIRED: new_quizzes_require_migration?, - NEW_QUIZZES_UNATTACHED_BANK_MIGRATIONS: new_quizzes_unattached_bank_migrations_enabled? + NEW_QUIZZES_UNATTACHED_BANK_MIGRATIONS: new_quizzes_unattached_bank_migrations_enabled?, + COPY_COURSE_INTEGRATION_INFO: Account.site_admin.feature_enabled?(:course_copy_allow_copying_integration_info) }) end @@ -3045,6 +3046,7 @@ def copy_course @content_migration.migration_settings[:source_course_id] = @context.id @content_migration.migration_settings[:import_quizzes_next] = true if params.dig(:settings, :import_quizzes_next) @content_migration.migration_settings[:import_blueprint_settings] = true if params.dig(:settings, :import_blueprint_settings) + @content_migration.migration_settings[:copy_integration_info] = true if params.dig(:settings, :copy_integration_info) @content_migration.workflow_state = "created" if (adjust_dates = params[:adjust_dates]) && Canvas::Plugin.value_to_boolean(adjust_dates[:enabled]) params[:date_shift_options][adjust_dates[:operation]] = "1" diff --git a/app/models/content_migration.rb b/app/models/content_migration.rb index e925ee13a5db2..9799e332b0c91 100644 --- a/app/models/content_migration.rb +++ b/app/models/content_migration.rb @@ -713,6 +713,10 @@ def import_quizzes_next? Canvas::Plugin.value_to_boolean(migration_settings[:import_quizzes_next]) end + def copy_integration_info? + Canvas::Plugin.value_to_boolean(migration_settings[:copy_integration_info]) + end + def quizzes_next_migration? context.instance_of?(Course) && context.feature_enabled?(:quizzes_next) && diff --git a/app/models/importers/assignment_importer.rb b/app/models/importers/assignment_importer.rb index c4c0ea03734b5..b67d4a0852774 100644 --- a/app/models/importers/assignment_importer.rb +++ b/app/models/importers/assignment_importer.rb @@ -371,6 +371,11 @@ def self.import_from_migration(hash, context, migration, item = nil, quiz = nil) end end + if migration.copy_integration_info? + item.integration_id = hash[:integration_id] + item.integration_data = hash[:integration_data] + end + [:turnitin_enabled, :vericite_enabled].each do |prop| if !hash[prop].nil? && context.send(:"#{prop}?") item.send(:"#{prop}=", hash[prop]) diff --git a/config/feature_flags/00_standard.yml b/config/feature_flags/00_standard.yml index fc150014cfe73..51647dfc1e169 100644 --- a/config/feature_flags/00_standard.yml +++ b/config/feature_flags/00_standard.yml @@ -34,6 +34,17 @@ autodetect_field_separators_for_gradebook_exports: exports based on the number format for your language. applies_to: User custom_transition_proc: autodetect_field_separators_for_gradebook_exports_custom_transition_hook +course_copy_allow_copying_integration_info: + applies_to: SiteAdmin + state: hidden + display_name: "Course Copy: allow copying integration_id and integration_data" + description: Adds opt-in settings to copy integration_id and integration_data + on assignments during course copy. + environments: + ci: + state: allowed_on + development: + state: allowed_on common_cartridge_page_conversion: type: setting state: hidden diff --git a/lib/cc/assignment_resources.rb b/lib/cc/assignment_resources.rb index bbcb5d0353759..bffcc7401e2e9 100644 --- a/lib/cc/assignment_resources.rb +++ b/lib/cc/assignment_resources.rb @@ -272,10 +272,12 @@ def self.create_canvas_assignment(node, assignment, manifest = nil) graders_anonymous_to_graders grader_names_visible_to_final_grader anonymous_instructor_annotations - allowed_attempts] + allowed_attempts + integration_id] atts.each do |att| node.tag!(att, assignment.send(att)) if assignment.send(att) == false || assignment.send(att).present? end + node.tag!(:integration_data, assignment.integration_data.to_json) if assignment.integration_data.present? if assignment.external_tool_tag if (content = assignment.external_tool_tag.content) && content.is_a?(ContextExternalTool) if content.context == assignment.context diff --git a/lib/cc/importer/standard/assignment_converter.rb b/lib/cc/importer/standard/assignment_converter.rb index 78cff4696d9dc..81c82ac477810 100644 --- a/lib/cc/importer/standard/assignment_converter.rb +++ b/lib/cc/importer/standard/assignment_converter.rb @@ -147,10 +147,15 @@ def parse_canvas_assignment_data(meta_doc, html_doc = nil, assignment = {}) external_tool_data_json external_tool_link_settings_json turnitin_settings - time_zone_edited].each do |string_type| + time_zone_edited + integration_id].each do |string_type| val = get_node_val(meta_doc, string_type) assignment[string_type] = val unless val.nil? end + integration_data_val = get_node_val(meta_doc, "integration_data") + if integration_data_val.present? + assignment["integration_data"] = JSON.parse(integration_data_val) + end %w[turnitin_enabled vericite_enabled peer_reviews diff --git a/spec/models/content_migration/course_copy_assignments_spec.rb b/spec/models/content_migration/course_copy_assignments_spec.rb index 2080c00df7d3a..9d95c4f83e6b5 100644 --- a/spec/models/content_migration/course_copy_assignments_spec.rb +++ b/spec/models/content_migration/course_copy_assignments_spec.rb @@ -1199,5 +1199,50 @@ expect(a_to).to be_valid end end + + context "integration info copying" do + it "copies integration_id and integration_data when copy_integration_info is enabled" do + assignment_model(course: @copy_from, points_possible: 10, submission_types: "online_text_entry") + @assignment.update!(integration_id: "ext-system-42", integration_data: { "sis_source" => "ABC", "vendor_id" => 99 }) + + @cm.migration_settings[:copy_integration_info] = true + @cm.save! + + run_course_copy + + a_to = @copy_to.assignments.find_by(migration_id: mig_id(@assignment)) + expect(a_to.integration_id).to eq "ext-system-42" + expect(a_to.integration_data).to eq({ "sis_source" => "ABC", "vendor_id" => 99 }) + end + + it "does not copy integration_id or integration_data when copy_integration_info is not enabled" do + assignment_model(course: @copy_from, points_possible: 10, submission_types: "online_text_entry") + @assignment.update!(integration_id: "ext-system-42", integration_data: { "sis_source" => "ABC", "vendor_id" => 99 }) + + run_course_copy + + a_to = @copy_to.assignments.find_by(migration_id: mig_id(@assignment)) + expect(a_to.integration_id).to be_nil + expect(a_to.integration_data).to be_nil + end + + it "copies only populated fields when copy_integration_info is enabled" do + a1 = @copy_from.assignments.create!(title: "Only ID", submission_types: "online_text_entry", integration_id: "id-only") + a2 = @copy_from.assignments.create!(title: "Only Data", submission_types: "online_text_entry", integration_data: { "data" => "only" }) + + @cm.migration_settings[:copy_integration_info] = true + @cm.save! + + run_course_copy + + a1_to = @copy_to.assignments.find_by(migration_id: mig_id(a1)) + expect(a1_to.integration_id).to eq "id-only" + expect(a1_to.integration_data).to be_nil + + a2_to = @copy_to.assignments.find_by(migration_id: mig_id(a2)) + expect(a2_to.integration_id).to be_nil + expect(a2_to.integration_data).to eq({ "data" => "only" }) + end + end end end diff --git a/ui/features/content_migrations/react/components/migrator_forms/course_copy.tsx b/ui/features/content_migrations/react/components/migrator_forms/course_copy.tsx index dce6d1da5bbf0..bcd34fd7d3f4c 100644 --- a/ui/features/content_migrations/react/components/migrator_forms/course_copy.tsx +++ b/ui/features/content_migrations/react/components/migrator_forms/course_copy.tsx @@ -419,6 +419,7 @@ export const CourseCopyImporter = ({onSubmit, onCancel, isSubmitting}: CourseCop canImportBPSettings={ selectedCourse && showBpSettingImport ? selectedCourse.blueprint : false } + canCopyIntegrationInfo={!!ENV.COPY_COURSE_INTEGRATION_INFO} oldStartDate={parseDateToISOString(oldStartDate)} oldEndDate={parseDateToISOString(oldEndDate)} newStartDate={parseDateToISOString(newStartDate)} diff --git a/ui/features/copy_course/react/components/form/CopyCourseForm.tsx b/ui/features/copy_course/react/components/form/CopyCourseForm.tsx index 7b8b6114e7d16..ac7d17f5e0416 100644 --- a/ui/features/copy_course/react/components/form/CopyCourseForm.tsx +++ b/ui/features/copy_course/react/components/form/CopyCourseForm.tsx @@ -249,6 +249,7 @@ export const CopyCourseForm = ({ canAdjustDates={true} canSelectContent={true} canImportBPSettings={canImportBpSettings} + canCopyIntegrationInfo={!!ENV.COPY_COURSE_INTEGRATION_INFO} canImportAsNewQuizzes={canImportAsNewQuizzes} newStartDate={isoNewCourseStartDate} newEndDate={isoNewCourseEndDate} diff --git a/ui/shared/content-migrations/react/CommonMigratorControls/CommonMigratorControls.tsx b/ui/shared/content-migrations/react/CommonMigratorControls/CommonMigratorControls.tsx index 35065a3d1bb84..3a6e87f5647f8 100644 --- a/ui/shared/content-migrations/react/CommonMigratorControls/CommonMigratorControls.tsx +++ b/ui/shared/content-migrations/react/CommonMigratorControls/CommonMigratorControls.tsx @@ -40,6 +40,7 @@ type CommonMigratorControlsProps = { canOverwriteAssessmentContent?: boolean canAdjustDates?: boolean canImportBPSettings?: boolean + canCopyIntegrationInfo?: boolean onSubmit: onSubmitMigrationFormCallback onCancel: () => void fileUploadProgress: number | null @@ -129,6 +130,7 @@ export const CommonMigratorControls = ({ canOverwriteAssessmentContent = false, canAdjustDates = false, canImportBPSettings = false, + canCopyIntegrationInfo = false, onSubmit, onCancel, isSubmitting, @@ -149,6 +151,7 @@ export const CommonMigratorControls = ({ !!ENV.NEW_QUIZZES_MIGRATION_DEFAULT, ) const [overwriteAssessmentContent, setOverwriteAssessmentContent] = useState(false) + const [copyIntegrationInfo, setCopyIntegrationInfo] = useState(false) const [showAdjustDates, setShowAdjustDates] = useState(false) const [dateAdjustmentConfig, setDateAdjustmentConfig] = useState({ adjust_dates: { @@ -199,6 +202,7 @@ export const CommonMigratorControls = ({ } canImportAsNewQuizzes && (data.settings.import_quizzes_next = importAsNewQuizzes) canOverwriteAssessmentContent && (data.settings.overwrite_quizzes = overwriteAssessmentContent) + canCopyIntegrationInfo && (data.settings.copy_integration_info = copyIntegrationInfo) onSubmit(data) }, [ selectiveImport, @@ -211,6 +215,8 @@ export const CommonMigratorControls = ({ importAsNewQuizzes, canOverwriteAssessmentContent, overwriteAssessmentContent, + canCopyIntegrationInfo, + copyIntegrationInfo, onSubmit, ]) @@ -265,6 +271,20 @@ export const CommonMigratorControls = ({ />, ] : []), + ...(canCopyIntegrationInfo + ? [ + ) => + setCopyIntegrationInfo(e.target.checked) + } + />, + ] + : []), ] const allContentText = ( diff --git a/ui/shared/content-migrations/react/CommonMigratorControls/__tests__/CommonMigratorControls.test.tsx b/ui/shared/content-migrations/react/CommonMigratorControls/__tests__/CommonMigratorControls.test.tsx index fbe2e2f91eb00..0beb7ba162695 100644 --- a/ui/shared/content-migrations/react/CommonMigratorControls/__tests__/CommonMigratorControls.test.tsx +++ b/ui/shared/content-migrations/react/CommonMigratorControls/__tests__/CommonMigratorControls.test.tsx @@ -131,6 +131,20 @@ describe('CommonMigratorControls', () => { ) }) + it('calls onSubmit with copy_integration_info', async () => { + renderComponent({canCopyIntegrationInfo: true}) + await userEvent.click( + screen.getByRole('checkbox', {name: 'Copy assignment integration IDs and data'}), + ) + await userEvent.click(screen.getByRole('button', {name: 'Add to Import Queue'})) + + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + settings: expect.objectContaining({copy_integration_info: true}), + }), + ) + }) + it('calls onSubmit with all data', async () => { renderComponent({ canSelectContent: true, diff --git a/ui/shared/global/env/ContentMigrations.d.ts b/ui/shared/global/env/ContentMigrations.d.ts index 553a5ed5afbef..52937025972dd 100644 --- a/ui/shared/global/env/ContentMigrations.d.ts +++ b/ui/shared/global/env/ContentMigrations.d.ts @@ -35,6 +35,7 @@ export interface EnvContentMigrations { NEW_QUIZZES_UNATTACHED_BANK_MIGRATIONS?: boolean EXPORT_WARNINGS?: string[] SHOW_BP_SETTINGS_IMPORT_OPTION?: boolean + COPY_COURSE_INTEGRATION_INFO?: boolean SHOW_SELECT?: boolean OLD_START_DATE?: string OLD_END_DATE?: string