canvas-lms/lib/sis/enrollment_importer.rb

513 lines
24 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 EnrollmentImporter < BaseImporter
BATCH_SIZE = 100
def process(messages)
i = Work.new(@batch, @root_account, @logger, messages)
Enrollment.suspend_callbacks(:set_update_cached_due_dates,
:add_to_favorites_later,
:recache_course_grade_distribution,
:update_user_account_associations_if_necessary) do
User.skip_updating_account_associations do
Enrollment.process_as_sis(@sis_options) do
yield i
while i.any_left_to_process?
i.process_batch
end
end
end
end
i.enrollments_to_update_sis_batch_ids.uniq.sort.in_groups_of(1000, false) do |batch|
Enrollment.where(id: batch).update_all(sis_batch_id: @batch.id)
# update observer enrollments linked to the above if they have a sis_batch_id
Shard.partition_by_shard(batch) do |shard_enrollment_ids|
Enrollment.where.not(sis_batch_id: nil)
.joins("INNER JOIN #{Enrollment.quoted_table_name} AS se ON enrollments.associated_user_id=se.user_id AND enrollments.course_section_id=se.course_section_id")
.where(se: { id: shard_enrollment_ids })
.in_batches(of: 10_000)
.update_all(sis_batch_id: @batch.id)
end
end
# We batch these up at the end because we don't want to keep touching the same course over and over,
# and to avoid hitting other callbacks for the course (especially broadcast_policy)
if Course.method_defined?(:recache_grade_distribution)
i.courses_to_touch_ids.to_a.in_groups_of(1000, false) do |batch|
courses = Course.where(id: batch)
courses.touch_all
courses.each(&:recache_grade_distribution)
end
end
i.courses_to_recache_due_dates.to_a.in_groups_of(1000, false) do |batch|
batch.each do |course_id, user_ids|
SubmissionLifecycleManager.recompute_users_for_course(user_ids.uniq, course_id, nil, sis_import: true, update_grades: true)
end
end
# We batch these up at the end because normally a user would get several enrollments, and there's no reason
# to update their account associations on each one.
i.incrementally_update_account_associations
User.update_account_associations(i.update_account_association_user_ids.to_a, account_chain_cache: i.account_chain_cache)
i.users_to_touch_ids.to_a.in_groups_of(1000, false) do |batch|
User.where(id: batch).touch_all
User.where(id: UserObserver.where(user_id: batch).select(:observer_id)).touch_all
ids_to_touch = (batch + UserObserver.where(user_id: batch).pluck(:observer_id)).uniq
User.touch_and_clear_cache_keys(ids_to_touch, :enrollments) if ids_to_touch.any?
end
i.enrollments_to_add_to_favorites.filter_map(&:id).each_slice(1000) do |sliced_ids|
Enrollment.delay(priority: Delayed::LOW_PRIORITY, strand: "batch_add_to_favorites_#{@root_account.global_id}")
.batch_add_to_favorites(sliced_ids)
end
if i.enrollments_to_delete.any?
new_data = Enrollment::BatchStateUpdater.destroy_batch(
i.enrollments_to_delete,
sis_batch: @batch,
ignore_due_date_caching_for: i.courses_to_recache_due_dates
)
i.roll_back_data.push(*new_data)
end
SisBatchRollBackData.bulk_insert_roll_back_data(i.roll_back_data)
i.success_count + i.enrollments_to_delete.count
end
class Work
attr_accessor :enrollments_to_update_sis_batch_ids,
:courses_to_touch_ids,
:incrementally_update_account_associations_user_ids,
:update_account_association_user_ids,
:account_chain_cache,
:users_to_touch_ids,
:success_count,
:courses_to_recache_due_dates,
:enrollments_to_add_to_favorites,
:roll_back_data,
:enrollments_to_delete
def initialize(batch, root_account, logger, messages)
@batch = batch
@root_account = root_account
@logger = logger
@messages = messages
@update_account_association_user_ids = Set.new
@incrementally_update_account_associations_user_ids = Set.new
@users_to_touch_ids = Set.new
@courses_to_touch_ids = Set.new
@courses_to_recache_due_dates = {}
@enrollments_to_add_to_favorites = []
@enrollments_to_update_sis_batch_ids = []
@roll_back_data = []
@enrollments_to_delete = []
@account_chain_cache = {}
@course = @section = nil
@course_roles_by_account_id = {}
@enrollment_batch = []
@success_count = 0
end
# Pass a single instance of SIS::Models::Enrollment
def add_enrollment(enrollment)
raise ImportError, "No course_id or section_id given for an enrollment" unless enrollment.valid_context?
raise ImportError, "No user_id given for an enrollment" unless enrollment.valid_user?
raise ImportError, "Improper status \"#{enrollment.status}\" for an enrollment" unless enrollment.valid_status?
return if @batch.skip_deletes? && enrollment.status =~ /deleted/i
@enrollment_batch << enrollment
process_batch if @enrollment_batch.size >= BATCH_SIZE
end
def any_left_to_process?
!@enrollment_batch.empty?
end
def process_batch
return unless any_left_to_process?
enrollment_info = nil
until @enrollment_batch.empty?
enrollment_info = @enrollment_batch.shift
@last_section = @section if @section
@last_course = @course if @course
# reset the cached course/section if they don't match this row
if @course && enrollment_info.course_id.present? && @course.sis_source_id != enrollment_info.course_id
@course = nil
@section = nil
end
if @section && enrollment_info.section_id.present? && @section.sis_source_id != enrollment_info.section_id
@section = nil
end
if enrollment_info.root_account_id.present?
root_account = root_account_from_id(enrollment_info.root_account_id, enrollment_info)
next unless root_account
else
root_account = @root_account
end
pseudo = if enrollment_info.user_integration_id.blank?
root_account.pseudonyms.where(sis_user_id: enrollment_info.user_id).take
else
root_account.pseudonyms.where(integration_id: enrollment_info.user_integration_id).take
end
unless pseudo
err = +"User not found for enrollment "
err << "(User ID: #{enrollment_info.user_id}, Course ID: #{enrollment_info.course_id}, Section ID: #{enrollment_info.section_id})"
@messages << SisBatch.build_error(enrollment_info.csv, err, sis_batch: @batch, row: enrollment_info.lineno, row_info: enrollment_info.row_info)
next
end
user = pseudo.user
if root_account != @root_account && !SisPseudonym.for(user, @root_account, type: :implicit, require_sis: false)
err = "User #{enrollment_info.root_account_id}:#{enrollment_info.user_id} does not have a usable login for this account"
@messages << SisBatch.build_error(enrollment_info.csv, err, sis_batch: @batch, row: enrollment_info.lineno, row_info: enrollment_info.row_info)
next
end
@course ||= @root_account.all_courses.where(sis_source_id: enrollment_info.course_id).take unless enrollment_info.course_id.blank?
@section ||= @root_account.course_sections.where(sis_source_id: enrollment_info.section_id).take unless enrollment_info.section_id.blank?
if @course.nil? && @section.nil?
message = "Neither course nor section existed for user enrollment " \
"(Course ID: #{enrollment_info.course_id}, Section ID: #{enrollment_info.section_id}, User ID: #{enrollment_info.user_id})"
@messages << SisBatch.build_error(enrollment_info.csv, message, sis_batch: @batch, row: enrollment_info.lineno, row_info: enrollment_info.row_info)
next
end
if enrollment_info.section_id.present? && !@section
@course = nil
message = "An enrollment referenced a non-existent section #{enrollment_info.section_id}"
@messages << SisBatch.build_error(enrollment_info.csv, message, sis_batch: @batch, row: enrollment_info.lineno, row_info: enrollment_info.row_info)
next
end
if enrollment_info.course_id.present? && !@course
@section = nil
message = "An enrollment referenced a non-existent course #{enrollment_info.course_id}"
@messages << SisBatch.build_error(enrollment_info.csv, message, sis_batch: @batch, row: enrollment_info.lineno, row_info: enrollment_info.row_info)
next
end
# reset cached/inferred course and section if they don't match with the opposite piece that was
# explicitly provided
@section = @course.default_section(include_xlists: true) if @section.nil? || (enrollment_info.section_id.blank? && !@section.default_section)
@course = @section.course if @course.nil? ||
(enrollment_info.course_id.blank? && @course.id != @section.course_id) ||
(@course.id != @section.course_id && @section.nonxlist_course_id == @course.id)
if @course.id != @section.course_id
message = "An enrollment listed a section (#{enrollment_info.section_id}) " \
"and a course (#{enrollment_info.course_id}) that are unrelated " \
"for user (#{enrollment_info.user_id})"
@messages << SisBatch.build_error(enrollment_info.csv, message, sis_batch: @batch, row: enrollment_info.lineno, row_info: enrollment_info.row_info)
next
end
# preload the course object to avoid later queries for it
@section.course = @course
# cache available course roles for this account
@course_roles_by_account_id[@course.account_id] ||= @course.account.available_course_roles
# commit pending incremental account associations
incrementally_update_account_associations if @section != @last_section && !@incrementally_update_account_associations_user_ids.empty?
associated_user_id = nil
temporary_enrollment_source_user_id = nil
role = nil
if enrollment_info.role_id
role = @course_roles_by_account_id[@course.account_id].detect { |r| r.global_id == Shard.global_id_for(enrollment_info.role_id, @course.shard) }
end
role ||= @course_roles_by_account_id[@course.account_id].detect { |r| r.name == enrollment_info.role }
type = if role
role.base_role_type
else
case enrollment_info.role
when /\Ateacher\z/i
"TeacherEnrollment"
when /\Astudent/i
"StudentEnrollment"
when /\Ata\z/i
"TaEnrollment"
when /\Aobserver\z/i
"ObserverEnrollment"
when /\Adesigner\z/i
"DesignerEnrollment"
end
end
unless type
message = "Improper role \"#{enrollment_info.role}\" for an enrollment"
@messages << SisBatch.build_error(enrollment_info.csv, message, sis_batch: @batch, row: enrollment_info.lineno, row_info: enrollment_info.row_info)
next
end
if %w[StudentEnrollment ObserverEnrollment].include?(type) && MasterCourses::MasterTemplate.is_master_course?(@course)
message = "#{(type == "StudentEnrollment") ? "Student" : "Observer"} enrollment for \"#{enrollment_info.user_id}\" not allowed in blueprint course \"#{@course.sis_course_id}\""
@messages << SisBatch.build_error(enrollment_info.csv, message, sis_batch: @batch, row: enrollment_info.lineno, row_info: enrollment_info.row_info)
next
end
role ||= Role.get_built_in_role(type, root_account_id: @root_account.id)
if enrollment_info.associated_user_id && type == "ObserverEnrollment"
a_pseudo = root_account.pseudonyms.where(sis_user_id: enrollment_info.associated_user_id).take
if a_pseudo
associated_user_id = a_pseudo.user_id
else
message = "An enrollment referenced a non-existent associated user #{enrollment_info.associated_user_id}"
@messages << SisBatch.build_error(enrollment_info.csv, message, sis_batch: @batch, row: enrollment_info.lineno, row_info: enrollment_info.row_info)
next
end
end
if enrollment_info.temporary_enrollment_source_user_id
a_pseudo = root_account.pseudonyms.where(sis_user_id: enrollment_info.temporary_enrollment_source_user_id).take
if a_pseudo
temporary_enrollment_source_user_id = a_pseudo.user_id
else
message = "An enrollment referenced a non-existent temporary enrollment source user #{enrollment_info.temporary_enrollment_source_user_id}"
@messages << SisBatch.build_error(enrollment_info.csv, message, sis_batch: @batch, row: enrollment_info.lineno, row_info: enrollment_info.row_info)
next
end
end
enrollment = @section.all_enrollments.where(user_id: user,
type:,
associated_user_id:,
temporary_enrollment_source_user_id:,
role_id: role).take
enrollment ||= Enrollment.typed_enrollment(type).new
enrollment.root_account = @root_account
enrollment.user = user
enrollment.type = type
enrollment.associated_user_id = associated_user_id
enrollment.role = role
enrollment.course = @course
enrollment.course_section = @section
if enrollment_info.limit_section_privileges
enrollment.limit_privileges_to_course_section = Canvas::Plugin.value_to_boolean(enrollment_info.limit_section_privileges)
end
if @course.root_account&.feature_enabled?(:temporary_enrollments)
enrollment.temporary_enrollment_source_user_id = temporary_enrollment_source_user_id
end
next if enrollment_status(associated_user_id,
temporary_enrollment_source_user_id,
enrollment,
enrollment_info,
pseudo,
role,
user)
unless enrollment.stuck_sis_fields.intersect?([:start_at, :end_at])
enrollment.start_at = enrollment_info.start_date
enrollment.end_at = enrollment_info.end_date
end
@courses_to_touch_ids.add(enrollment.course_id)
if enrollment.should_update_user_account_association? && !%w[creation_pending deleted].include?(user.workflow_state)
if enrollment.new_record? && !@update_account_association_user_ids.include?(user.id)
@incrementally_update_account_associations_user_ids.add(user.id)
else
@update_account_association_user_ids.add(user.id)
end
end
enrollment.sis_pseudonym_id = pseudo.id
if enrollment.changed?
@users_to_touch_ids.add(user.id)
if enrollment.workflow_state_changed?
if enrollment_needs_due_date_recaching?(enrollment)
courses_to_recache_due_dates[enrollment.course_id] ||= []
courses_to_recache_due_dates[enrollment.course_id] << enrollment.user_id
end
if enrollment.workflow_state == "active"
enrollments_to_add_to_favorites << enrollment
end
end
enrollment.sis_batch_id = enrollment_info.sis_batch_id if enrollment_info.sis_batch_id
enrollment.sis_batch_id = @batch.id
enrollment.skip_touch_user = true
begin
if Canvas::Plugin.value_to_boolean(enrollment_info.notify)
enrollment.save!
else
enrollment.save_without_broadcasting!
end
rescue ActiveRecord::RecordInvalid
msg = "An enrollment did not pass validation "
msg += "(" + "course: #{enrollment_info.course_id}, section: #{enrollment_info.section_id}, "
msg += "user: #{enrollment_info.user_id}, role: #{enrollment_info.role}, error: " +
msg += enrollment.errors.full_messages.join(",") + ")"
@messages << SisBatch.build_error(enrollment_info.csv, msg, sis_batch: @batch, row: enrollment_info.lineno, row_info: enrollment_info.row_info)
next
rescue ActiveRecord::RecordNotUnique
if @retry == true
msg = "An enrollment failed to save "
msg += "(course: #{enrollment_info.course_id}, section: #{enrollment_info.section_id}, "
msg += "user: #{enrollment_info.user_id}, role: #{enrollment_info.role}, error: " +
msg += enrollment.errors.full_messages.join(",") + ")"
@messages << SisBatch.build_error(enrollment_info.csv, msg, sis_batch: @batch, row: enrollment_info.lineno, row_info: enrollment_info.row_info)
@retry = false
else
@enrollment_batch.unshift(enrollment_info)
@retry = true
end
next
end
data = SisBatchRollBackData.build_data(sis_batch: @batch, context: enrollment)
@roll_back_data << data if data
else
@enrollments_to_update_sis_batch_ids << enrollment.id
end
@success_count += 1
end
end
def root_account_from_id(_root_account_id, _enrollment_info)
nil
end
def incrementally_update_account_associations
if @incrementally_update_account_associations_user_ids.length < 10
@update_account_association_user_ids.merge(@incrementally_update_account_associations_user_ids)
else
User.update_account_associations(@incrementally_update_account_associations_user_ids.to_a,
incremental: true,
precalculated_associations: User.calculate_account_associations_from_accounts(
[@last_course.account_id, @last_section.nonxlist_course.try(:account_id)].compact.uniq, @account_chain_cache
))
end
@incrementally_update_account_associations_user_ids = Set.new
end
private
def enrollment_status(associated_user_id, temporary_enrollment_source_user_id, enrollment, enrollment_info, pseudo, role, user)
all_done = false
if enrollment.deleted?
message = if user.deleted?
invalid_active_enrollment(enrollment, enrollment_info)
elsif pseudo.deleted?
"Attempted enrolling with deleted sis login #{pseudo.unique_id} in course #{enrollment_info.course_id}"
end
if message
@messages << SisBatch.build_error(enrollment_info.csv,
message,
sis_batch: @batch,
row: enrollment_info.lineno,
row_info: enrollment_info.row_info)
return true
end
end
case enrollment_info.status
when /\Aactive/i
message = set_enrollment_workflow_state(enrollment, enrollment_info, pseudo, user)
@messages << SisBatch.build_error(enrollment_info.csv, message, sis_batch: @batch, row: enrollment_info.lineno, row_info: enrollment_info.row_info) if message
when /\Acompleted/i
completed_status(enrollment)
when /\Ainactive/i
enrollment.workflow_state = "inactive"
when /\Adeleted_last_completed/i
# if any matching enrollment for the same user in the same course
# exists, we will mark the enrollment as deleted, but if it is the
# last enrollment it gets marked as completed
if @course.enrollments.active
.where(user:, associated_user_id:, temporary_enrollment_source_user_id:, role:)
.where.not(id: enrollment.id).exists?
all_done = deleted_status(enrollment)
else
completed_status(enrollment)
end
when /\Adeleted/i
# we support creating deleted enrollments, but we want to preserve
# the state for roll_back_data so only set workflow_state for new
# objects otherwise delete them in a batch at the end unless it is
# already deleted.
all_done = deleted_status(enrollment)
end
all_done
end
def completed_status(enrollment)
enrollment.workflow_state = "completed"
enrollment.completed_at = Time.zone.now
end
def deleted_status(enrollment)
if enrollment.id.nil?
enrollment.workflow_state = "deleted"
# this will allow the enrollment to continue to be created
false
else
if enrollment.workflow_state == "deleted"
@enrollments_to_update_sis_batch_ids << enrollment.id
@success_count += 1
else
@enrollments_to_delete << enrollment
end
# we are done and we can go to the next enrollment
true
end
end
def set_enrollment_workflow_state(enrollment, enrollment_info, pseudo, user)
message = nil
# the user is active, and the pseudonym is active
if user.workflow_state != "deleted" && pseudo.workflow_state != "deleted"
enrollment.workflow_state = "active"
# the user is active, but the pseudonym is deleted, check for other active pseudonym
elsif user.workflow_state != "deleted" && pseudo.workflow_state == "deleted"
if @root_account.pseudonyms.active.where(user_id: user).where("sis_user_id != ? OR sis_user_id IS NULL", enrollment_info.user_id).exists?
enrollment.workflow_state = "active"
message = "Enrolled a user #{enrollment_info.user_id} in course #{enrollment_info.course_id}, but referenced a deleted sis login"
else
message = invalid_active_enrollment(enrollment, enrollment_info)
end
else # the user is deleted
message = invalid_active_enrollment(enrollment, enrollment_info)
end
message
end
def invalid_active_enrollment(enrollment, enrollment_info)
enrollment.workflow_state = "deleted" unless enrollment.deleted?
"Attempted enrolling of deleted user #{enrollment_info.user_id} in course #{enrollment_info.course_id}"
end
def enrollment_needs_due_date_recaching?(enrollment)
unless %w[active inactive].include? enrollment.workflow_state_before_last_save
return %w[active inactive].include? enrollment.workflow_state
end
false
end
end
end
end