# frozen_string_literal: true # # Copyright (C) 2011 - present Instructure, Inc. # # This file is part of Canvas. # # Canvas is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License as published by the Free # Software Foundation, version 3 of the License. # # Canvas is distributed in the hope that it will be useful, but WITHOUT ANY # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR # A PARTICULAR PURPOSE. See the GNU Affero General Public License for more # details. # # You should have received a copy of the GNU Affero General Public License along # with this program. If not, see . # module SIS class CourseImporter < BaseImporter def process(messages) courses_to_update_sis_batch_id = [] course_ids_to_update_associations = [].to_set blueprint_associations = {} importer = Work.new(@batch, @root_account, @logger, courses_to_update_sis_batch_id, course_ids_to_update_associations, messages, @batch_user, blueprint_associations) Course.suspend_callbacks(:update_enrollments_later, :update_enrollment_states_if_necessary) do Course.process_as_sis(@sis_options) do Course.skip_updating_account_associations do yield importer end end end Course.update_account_associations(course_ids_to_update_associations.to_a) unless course_ids_to_update_associations.empty? courses_to_update_sis_batch_id.in_groups_of(1000, false) do |courses| Course.where(id: courses).update_all(sis_batch_id: @batch.id) end SisBatchRollBackData.bulk_insert_roll_back_data(importer.roll_back_data) MasterCourses::MasterTemplate.create_associations_from_sis(@root_account, blueprint_associations, messages, @batch_user) importer.success_count end class Work attr_accessor :success_count, :roll_back_data def initialize(batch, root_account, logger, a1, a2, m, batch_user, blueprint_associations) @batch = batch @batch_user = batch_user @root_account = root_account @courses_to_update_sis_batch_id = a1 @course_ids_to_update_associations = a2 @roll_back_data = [] @blueprint_associations = blueprint_associations @messages = m @logger = logger @success_count = 0 end def add_course(course_id, term_id, account_id, fallback_account_id, status, start_date, end_date, abstract_course_id, short_name, long_name, integration_id, course_format, blueprint_course_id, grade_passback_setting, homeroom_course, friendly_name) state_changes = [] raise ImportError, "No course_id given for a course" if course_id.blank? raise ImportError, "No short_name given for course #{course_id}" if short_name.blank? && abstract_course_id.blank? raise ImportError, "No long_name given for course #{course_id}" if long_name.blank? && abstract_course_id.blank? raise ImportError, "Improper status \"#{status}\" for course #{course_id}" unless /\A(active|deleted|completed|unpublished|published)/i.match?(status) raise ImportError, "Invalid course_format \"#{course_format}\" for course #{course_id}" unless course_format.blank? || course_format =~ /\A(online|on_campus|blended|not_set)/i valid_grade_passback_settings = %w[nightly_sync disabled not_set] raise ImportError, "Invalid grade_passback_setting \"#{grade_passback_setting}\" for course #{course_id}" unless grade_passback_setting.blank? || valid_grade_passback_settings.include?(grade_passback_setting.downcase.strip) return if @batch.skip_deletes? && status =~ /deleted/i Course.unique_constraint_retry do course = @root_account.all_courses.find_by(sis_source_id: course_id) if course.nil? course = Course.new state_changes << :created else state_changes << :updated end course.saved_by = :sis_import course_enrollment_term_id_stuck = course.stuck_sis_fields.include?(:enrollment_term_id) if !course_enrollment_term_id_stuck && term_id term = @root_account.enrollment_terms.active.find_by(sis_source_id: term_id) end course.enrollment_term = term if term course.root_account = @root_account account = nil account = @root_account.all_accounts.find_by(sis_source_id: account_id) if account_id.present? account ||= @root_account.all_accounts.find_by(sis_source_id: fallback_account_id) if fallback_account_id.present? if account_id.present? && !account raise ImportError, "Account not found \"#{account_id}\" for course #{course_id}" end course_account_stuck = course.stuck_sis_fields.include?(:account_id) if !course_account_stuck && account&.active? course.account = account end if course.account&.deleted? raise ImportError, "Cannot restore course #{course_id} because the associated account #{course.account.sis_source_id} is deleted" end course.account ||= @root_account update_account_associations = course.account_id_changed? || course.root_account_id_changed? if account&.enable_as_k5_account? && friendly_name.present? course.friendly_name = friendly_name end course.integration_id = integration_id course.sis_source_id = course_id active_state = status.casecmp?("published") ? "available" : "claimed" unless course.stuck_sis_fields.include?(:workflow_state) if %w[active unpublished published].include?(status.downcase) case course.workflow_state when "completed" # not using active state here, because it has always been set to available # and customers have used this as a workaround to publishing courses. conclude, then restore. course.workflow_state = "available" state_changes << :unconcluded when "deleted" course.workflow_state = active_state state_changes << :restored when "created", "claimed", nil course.workflow_state = active_state state_changes << :published if active_state == "available" end elsif /deleted/i.match?(status) course.workflow_state = "deleted" state_changes << :deleted elsif /completed/i.match?(status) course.workflow_state = "completed" state_changes << :concluded end end course_dates_stuck = !!course.stuck_sis_fields.intersect?([:start_at, :conclude_at]) unless course_dates_stuck if start_date == "" && end_date == "" course.restrict_enrollments_to_course_dates = false end course.start_at = start_date unless start_date == "not_present" course.start_at = nil if start_date == "" course.conclude_at = end_date unless end_date == "not_present" course.conclude_at = nil if end_date == "" if !course.stuck_sis_fields.include?(:restrict_enrollments_to_course_dates) && !(start_date == "not_present" && end_date == "not_present") course.restrict_enrollments_to_course_dates = (start_date.present? || end_date.present?) end end abstract_course = nil if abstract_course_id.present? abstract_course = @root_account.root_abstract_courses.find_by(sis_source_id: abstract_course_id) @messages << "unknown abstract course id #{abstract_course_id}, ignoring abstract course reference" unless abstract_course end if abstract_course if term_id.blank? && course.enrollment_term_id != abstract_course.enrollment_term && !course_enrollment_term_id_stuck course.send(:association_instance_set, :enrollment_term, nil) course.enrollment_term_id = abstract_course.enrollment_term_id end if account_id.blank? && course.account_id != abstract_course.account_id course.send(:association_instance_set, :account, nil) course.account_id = abstract_course.account_id end end course.abstract_course = abstract_course # only update the name/short_name on new records, and ones that haven't been changed # since the last sis import course_course_code_stuck = course.stuck_sis_fields.include?(:course_code) if course.course_code.blank? || !course_course_code_stuck if short_name.present? course.course_code = short_name elsif abstract_course && course.course_code.blank? course.course_code = abstract_course.short_name end end course_name_stuck = course.stuck_sis_fields.include?(:name) if course.name.blank? || !course_name_stuck if long_name.present? course.name = long_name elsif abstract_course && course.name.blank? course.name = abstract_course.name end end update_enrollments = !course.new_record? && !!course.changes.keys.intersect?(%w[workflow_state name course_code]) # republish course paces if necessary if !course.new_record? && course.account.feature_enabled?(:course_paces) && course.changes.keys.intersect?(%w[start_at conclude_at restrict_enrollments_to_course_dates]) course.course_paces.find_each(&:create_publish_progress) end if course_format course_format = nil if course_format == "not_set" if course_format != course.course_format course.settings_will_change! course.course_format = course_format end end if grade_passback_setting grade_passback_setting_stuck = course.stuck_sis_fields.include?(:grade_passback_setting) unless grade_passback_setting_stuck grade_passback_setting = nil if grade_passback_setting == "not_set" course.grade_passback_setting = grade_passback_setting end end if homeroom_course course.homeroom_course = Canvas::Plugin.value_to_boolean(homeroom_course) end if course.changed? course.templated_courses.each do |templated_course| templated_course.root_account = @root_account templated_course.account = course.account if !templated_course.stuck_sis_fields.include?(:account_id) && !course_account_stuck templated_course.name = course.name if !templated_course.stuck_sis_fields.include?(:name) && !course_name_stuck templated_course.course_code = course.course_code if !templated_course.stuck_sis_fields.include?(:course_code) && !course_course_code_stuck templated_course.enrollment_term = course.enrollment_term if !templated_course.stuck_sis_fields.include?(:enrollment_term_id) && !course_enrollment_term_id_stuck if !templated_course.stuck_sis_fields.intersect?(%i[start_at conclude_at restrict_enrollments_to_course_dates]) && !course_dates_stuck templated_course.start_at = course.start_at templated_course.conclude_at = course.conclude_at templated_course.restrict_enrollments_to_course_dates = course.restrict_enrollments_to_course_dates end templated_course.sis_batch_id = @batch.id @course_ids_to_update_associations.add(templated_course.id) if templated_course.account_id_changed? || templated_course.root_account_id_changed? if templated_course.valid? changes = templated_course.changes templated_course.save_without_broadcasting! Auditors::Course.record_updated(templated_course, @batch_user, changes, source: :sis, sis_batch_id: @batch_id) else msg = "A (templated) course did not pass validation " \ "(course: #{course_id} / #{short_name}, error: " \ "#{templated_course.errors.full_messages.join(",")})" raise ImportError, msg end end course.sis_batch_id = @batch.id if course.valid? course_changes = course.changes course.save_without_broadcasting! auditor_state_changes(course, state_changes, course_changes) data = SisBatchRollBackData.build_data(sis_batch: @batch, context: course) @roll_back_data << data if data else msg = "A course did not pass validation " \ "(course: #{course_id} / #{short_name}, error: " \ "#{course.errors.full_messages.join(",")})" raise ImportError, msg end @course_ids_to_update_associations.add(course.id) if update_account_associations else @courses_to_update_sis_batch_id << course.id end if blueprint_course_id && !course.deleted? case blueprint_course_id when "dissociate" MasterCourses::ChildSubscription.active.find_by(child_course_id: course.id)&.destroy else @blueprint_associations[blueprint_course_id] ||= [] @blueprint_associations[blueprint_course_id] << course_id end end enrollment_data = course.update_enrolled_users(sis_batch: @batch) if update_enrollments course.update_enrollment_states_if_necessary @roll_back_data.push(*enrollment_data) if enrollment_data maybe_write_roll_back_data @success_count += 1 end end def maybe_write_roll_back_data if @roll_back_data.count > 1000 SisBatchRollBackData.bulk_insert_roll_back_data(@roll_back_data) @roll_back_data = [] end end def auditor_state_changes(course, state_changes, changes = {}) options = { source: :sis, sis_batch: @batch } state_changes.each do |state_change| case state_change when :created Auditors::Course.record_created(course, @batch_user, changes, options) when :updated Auditors::Course.record_updated(course, @batch_user, changes, options) when :concluded if Account.site_admin.feature_enabled?(:default_account_grading_scheme) && course.grading_standard_id.nil? && course.root_account.grading_standard_id course.update!(grading_standard_id: course.root_account.grading_standard_id) end Auditors::Course.record_concluded(course, @batch_user, options) when :unconcluded Auditors::Course.record_unconcluded(course, @batch_user, options) when :published Auditors::Course.record_published(course, @batch_user, options) when :deleted Auditors::Course.record_deleted(course, @batch_user, options) when :restored Auditors::Course.record_restored(course, @batch_user, options) end end end end end end