canvas-lms/lib/sis/course_importer.rb

320 lines
15 KiB
Ruby

# 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 <http://www.gnu.org/licenses/>.
#
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 == "<delete>" && end_date == "<delete>"
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 == "<delete>"
course.conclude_at = end_date unless end_date == "not_present"
course.conclude_at = nil if end_date == "<delete>"
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