canvas-lms/lib/sis/enrollment_importer.rb

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

513 lines
24 KiB
Ruby
Raw Normal View History

# frozen_string_literal: true
2011-02-01 09:57:29 +08:00
#
# Copyright (C) 2011 - present Instructure, Inc.
2011-02-01 09:57:29 +08:00
#
# 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
2011-02-01 09:57:29 +08:00
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
add ability to send notifications for SIS batch enrollment This change allows us to trigger course enrollment notifications via the SIS enrollment batch importer. The default will be to not broadcast the notifications unless the optional param is included and set to *true*. We only send out one notification as to avoid spam or duplicates in case of re-upload or a failed import. closes FOO-652 flag = none test plan: * create a new course and have an existing user to work with * ensure the communication channel for that user is verified * create an enrollments.csv with the required fields and omit the optional field `notify` * navigate to `/accounts/self/sis_import` * import the .csv from above * verify no notification is sent via MailCatcher or `/users/:id/messages` for the SIS user * add the `notify` header field to the same .csv with a value of `true` for the particular user enrollment * import the .csv and verify an email *was* sent * delete the newly added course enrollment for that user * import the same .csv and verify we don't send another enrollment notification for that user * ensure no documentation errors found for /doc/api/file.sis_csv.html with the newly added optional field name _notify_ Change-Id: Ibddb11bce765b3830370bc07219e34e5ec982f5d Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/241370 Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com> QA-Review: Ahmad Amireh <ahmad@instructure.com> Product-Review: Ahmad Amireh <ahmad@instructure.com> Reviewed-by: Cody Cutrer <cody@instructure.com>
2020-06-29 07:34:35 +08:00
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
2011-02-01 09:57:29 +08:00
end
end
end